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

- 新增 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
+149
View File
@@ -0,0 +1,149 @@
import { DatabaseSync } from 'node:sqlite'
export interface SnapshotRow {
id: number
spaceId: string
createdAt: number
cardCount: number
preview: string
reason: SnapshotReason
}
/** auto:编辑时定时保存;manual:手动保存;restore:恢复前自动备份当前状态 */
export type SnapshotReason = 'auto' | 'manual' | 'restore'
/** 每个空间最多保留的历史版本数 */
const MAX_SNAPSHOTS_PER_SPACE = 120
/**
* SQLite 存储,使用 Node 内置的 node:sqlite,不依赖原生编译模块,部署更省心。
* - documents:Yjs 文档的二进制状态
* - snapshots:空间的历史版本
*/
export class Storage {
private readonly db: DatabaseSync
constructor(file: string) {
this.db = new DatabaseSync(file)
this.db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS documents (
name TEXT PRIMARY KEY,
data BLOB NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
data BLOB NOT NULL,
state_vector BLOB NOT NULL,
card_count INTEGER NOT NULL,
preview TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS snapshots_space ON snapshots (space_id, created_at);
`)
}
loadDocument(name: string): Uint8Array | null {
const row = this.db.prepare('SELECT data FROM documents WHERE name = ?').get(name) as
| { data: Uint8Array }
| undefined
return row ? new Uint8Array(row.data) : null
}
saveDocument(name: string, data: Uint8Array) {
this.db
.prepare(
`INSERT INTO documents (name, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
)
.run(name, data, Date.now())
}
deleteDocument(name: string) {
this.db.prepare('DELETE FROM documents WHERE name = ?').run(name)
}
latestSnapshot(spaceId: string): { createdAt: number; stateVector: Uint8Array } | null {
const row = this.db
.prepare(
'SELECT created_at, state_vector FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT 1',
)
.get(spaceId) as { created_at: number; state_vector: Uint8Array } | undefined
return row ? { createdAt: row.created_at, stateVector: new Uint8Array(row.state_vector) } : null
}
addSnapshot(input: {
spaceId: string
data: Uint8Array
stateVector: Uint8Array
cardCount: number
preview: string
reason: SnapshotReason
}) {
const result = this.db
.prepare(
`INSERT INTO snapshots (space_id, created_at, data, state_vector, card_count, preview, reason)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
input.spaceId,
Date.now(),
input.data,
input.stateVector,
input.cardCount,
input.preview,
input.reason,
)
this.db
.prepare(
`DELETE FROM snapshots WHERE space_id = ? AND id NOT IN (
SELECT id FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT ?
)`,
)
.run(input.spaceId, input.spaceId, MAX_SNAPSHOTS_PER_SPACE)
return Number(result.lastInsertRowid)
}
listSnapshots(spaceId: string): SnapshotRow[] {
const rows = this.db
.prepare(
`SELECT id, space_id, created_at, card_count, preview, reason
FROM snapshots WHERE space_id = ? ORDER BY id DESC`,
)
.all(spaceId) as Array<{
id: number
space_id: string
created_at: number
card_count: number
preview: string
reason: SnapshotReason
}>
return rows.map((row) => ({
id: Number(row.id),
spaceId: row.space_id,
createdAt: row.created_at,
cardCount: row.card_count,
preview: row.preview,
reason: row.reason,
}))
}
loadSnapshot(spaceId: string, id: number): Uint8Array | null {
const row = this.db
.prepare('SELECT data FROM snapshots WHERE space_id = ? AND id = ?')
.get(spaceId, id) as { data: Uint8Array } | undefined
return row ? new Uint8Array(row.data) : null
}
deleteSnapshots(spaceId: string) {
this.db.prepare('DELETE FROM snapshots WHERE space_id = ?').run(spaceId)
}
close() {
this.db.close()
}
}