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

- 新增 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
+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()
})