自部署多端同步:服务端、实时协作、口令登录与油画加载动画

- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
This commit is contained in:
吴璨
2026-10-21 18:09:03 +08:00
parent c36123eb82
commit fad9760265
64 changed files with 6656 additions and 1103 deletions
+58
View File
@@ -0,0 +1,58 @@
import { expect, type Browser, type Page } from '@playwright/test'
export const E2E_PASSWORD = 'e2e-password'
export async function login(page: Page, path = '/') {
await page.goto(path)
await expect(page).toHaveURL(/\/login/)
await page.getByLabel('登录口令').fill(E2E_PASSWORD)
await page.getByLabel('登录口令').press('Enter')
await expect(page).toHaveURL(/\/s\//)
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
}
/** 新开一个独立的浏览器上下文,相当于另一台设备 */
export async function openDevice(browser: Browser, path = '/') {
const context = await browser.newContext({ reducedMotion: 'reduce', locale: 'zh-CN' })
const page = await context.newPage()
await login(page, path)
return { context, page }
}
/** 在当前空间正中新建一张便笺并改标题 */
export async function createNote(page: Page, title: string) {
const before = await page.locator('.note-card').count()
await page.getByRole('button', { name: '新便笺' }).click()
await expect(page.locator('.note-card')).toHaveCount(before + 1)
const card = page.locator('.note-card--selected')
await card.locator('.note-card__title').fill(title)
return card
}
/** 当前画布上所有卡片的标题(输入框的值不会出现在 DOM 属性里,只能这样读) */
export function noteTitles(page: Page) {
return page
.locator('.note-card__title')
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value))
}
export async function expectNoteTitle(page: Page, title: string, present = true) {
const poll = expect.poll(() => noteTitles(page), { timeout: 10_000 })
if (present) await poll.toContain(title)
else await poll.not.toContain(title)
}
export async function noteByTitle(page: Page, title: string) {
await expectNoteTitle(page, title)
const index = (await noteTitles(page)).indexOf(title)
return page.locator('.note-card').nth(index)
}
/** 新建一个空间并切换过去,避免用例之间互相影响 */
export async function createSpace(page: Page, name: string) {
await page.getByRole('button', { name: '创建空间' }).click()
await page.getByPlaceholder('例如:灵感收纳盒、工作面板…').fill(name)
await page.getByRole('button', { name: '立即创建' }).click()
await expect(page.locator('.space-title')).toContainText(name)
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
}
+191
View File
@@ -0,0 +1,191 @@
import { expect, test } from '@playwright/test'
import {
createNote,
createSpace,
expectNoteTitle,
login,
noteByTitle,
noteTitles,
openDevice,
} from './helpers'
test.describe('登录', () => {
test('未登录时跳转到登录页,口令错误会提示', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
await page.getByLabel('登录口令').fill('wrong-password')
await page.getByLabel('登录口令').press('Enter')
await expect(page.getByRole('alert')).toHaveText('口令不正确')
})
test('登录后看到初始空间,退出后需要重新登录', async ({ page }) => {
await login(page)
await expectNoteTitle(page, '欢迎来到无界笺')
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page).toHaveURL(/\/login/)
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
})
})
test.describe('多端同步', () => {
test('一台设备的修改会实时出现在另一台设备上', async ({ page, browser }) => {
await login(page)
await createSpace(page, '同步测试')
const spaceUrl = page.url()
const other = await openDevice(browser, spaceUrl)
await expect(other.page.locator('.space-title')).toContainText('同步测试')
// 新建
await createNote(page, '来自设备 A')
await expectNoteTitle(other.page, '来自设备 A')
// 修改内容
const remoteCard = await noteByTitle(other.page, '来自设备 A')
await remoteCard.locator('.note-card__content').fill('设备 B 补充的一句话')
const localCard = await noteByTitle(page, '来自设备 A')
await expect(localCard.locator('.note-card__content')).toHaveValue('设备 B 补充的一句话')
// 换色
await localCard.locator('.color-picker button[data-color="mint"]').click()
await expect(remoteCard).toHaveAttribute('data-color', 'mint')
// 删除
await localCard.locator('.card-icon-button').click()
await page.getByRole('button', { name: '删除' }).click()
await expectNoteTitle(other.page, '来自设备 A', false)
await other.context.close()
})
test('新建的空间会出现在其他设备的列表里', async ({ page, browser }) => {
await login(page)
const other = await openDevice(browser)
await createSpace(page, '新的标签')
await expect(other.page.locator('.space-list')).toContainText('新的标签')
await expect(page.locator('.devices__button')).toHaveText(/2/)
await other.context.close()
})
test('刷新页面后内容仍在', async ({ page }) => {
await login(page)
await createSpace(page, '持久化')
await createNote(page, '刷新也不会丢')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.reload()
await expectNoteTitle(page, '刷新也不会丢')
})
test('撤销只影响本机的操作', async ({ page, browser }) => {
await login(page)
await createSpace(page, '撤销测试')
const other = await openDevice(browser, page.url())
await createNote(other.page, '别人的便笺')
await expectNoteTitle(page, '别人的便笺')
await createNote(page, '我的便笺')
await page.locator('.vue-flow__pane').click({ position: { x: 40, y: 300 } })
await page.keyboard.press('Control+Z')
await page.keyboard.press('Control+Z')
await expectNoteTitle(page, '我的便笺', false)
await expectNoteTitle(page, '别人的便笺')
await expectNoteTitle(other.page, '我的便笺', false)
await other.context.close()
})
})
test.describe('画布操作', () => {
test('粘贴文字会生成一张新便笺', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await login(page)
await createSpace(page, '粘贴')
const before = await page.locator('.note-card').count()
await page.evaluate(() =>
navigator.clipboard.writeText('临时灵感\n把复制的文字直接变成一张新便笺。'),
)
await page.locator('.vue-flow__pane').click({ position: { x: 90, y: 300 } })
await page.keyboard.press('Control+V')
await expect(page.locator('.note-card')).toHaveCount(before + 1)
const card = await noteByTitle(page, '临时灵感')
await expect(card.locator('.note-card__content')).toHaveValue(
'临时灵感\n把复制的文字直接变成一张新便笺。',
)
})
test('上传图片后保存在服务器上', async ({ page }) => {
await login(page)
await createSpace(page, '图片')
// 1×1 PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
await page.locator('input[type="file"]').setInputFiles({
name: '小图.png',
mimeType: 'image/png',
buffer: png,
})
const image = page.locator('.note-card__image-wrap img')
await expect(image).toHaveAttribute('src', /^\/api\/images\/[a-f0-9]{40}\.png$/)
const response = await page.request.get((await image.getAttribute('src'))!)
expect(response.status()).toBe(200)
expect(response.headers()['content-type']).toBe('image/png')
})
test('全局搜索可以跳到其他空间的卡片', async ({ page }) => {
await login(page)
await createSpace(page, '搜索来源')
await createNote(page, '藏在别处的萤火虫')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await createSpace(page, '搜索起点')
await page.getByLabel('搜索便笺').fill('萤火虫')
await page.getByRole('button', { name: /藏在别处的萤火虫/ }).click()
await expect(page.locator('.space-title')).toContainText('搜索来源')
await expect(page.locator('.note-card--selected .note-card__title')).toHaveValue(
'藏在别处的萤火虫',
)
})
})
test.describe('历史版本', () => {
test('可以恢复到之前保存的版本', async ({ page }) => {
await login(page)
await createSpace(page, '版本')
await createNote(page, '想留住的想法')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.getByRole('button', { name: '历史版本' }).click()
await page.getByRole('button', { name: '保存当前版本' }).click()
await expect(page.locator('.version-item', { hasText: '手动保存' })).toHaveCount(1)
await page.getByRole('button', { name: '完成' }).click()
const card = await noteByTitle(page, '想留住的想法')
await card.locator('.card-icon-button').click()
await page.getByRole('button', { name: '删除' }).click()
await expectNoteTitle(page, '想留住的想法', false)
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.getByRole('button', { name: '历史版本' }).click()
const version = page.locator('.version-item', { hasText: '手动保存' })
await version.getByRole('button', { name: '恢复' }).click()
await version.getByRole('button', { name: '确认恢复' }).click()
await expectNoteTitle(page, '想留住的想法')
expect(await noteTitles(page)).toHaveLength(1)
})
})
test('加载动画会随机作画', async ({ browser }) => {
const context = await browser.newContext({ reducedMotion: 'no-preference' })
const page = await context.newPage()
await page.goto('/login')
const frame = page.frameLocator('.painting__tile iframe')
await expect(frame.locator('canvas').first()).toBeAttached({ timeout: 15_000 })
await expect(page.locator('.painting__caption')).toHaveText(/ · /)
await context.close()
})
-31
View File
@@ -1,31 +0,0 @@
import { test, expect } from '@playwright/test'
// See here how to get started:
// https://playwright.dev/docs/intro
test('opens the canvas and creates a note', async ({ page }) => {
await page.goto('/')
await expect(page.getByText('无界笺', { exact: true })).toBeVisible()
const notesBefore = await page.locator('.note-card').count()
await page.getByRole('button', { name: '新便笺' }).click()
await expect(page.locator('.note-card')).toHaveCount(notesBefore + 1)
})
test('pastes clipboard text into a new note on the canvas', async ({ page }) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('/')
await expect(page.getByText('无界笺', { exact: true })).toBeVisible()
const notesBefore = await page.locator('.note-card').count()
await page.evaluate(() =>
navigator.clipboard.writeText('临时灵感\n把复制的文字直接变成一张新便笺。'),
)
await page.locator('.vue-flow__pane').click({ position: { x: 90, y: 90 } })
await page.keyboard.press('Control+V')
await expect(page.locator('.note-card')).toHaveCount(notesBefore + 1)
const pastedCard = page.locator('.note-card').last()
await expect(pastedCard.locator('.note-card__title')).toHaveValue('临时灵感')
await expect(pastedCard.locator('.note-card__content')).toHaveValue(
'临时灵感\n把复制的文字直接变成一张新便笺。',
)
})