architecture: 数据唯一权威迁至服务器——本地降级为实时缓存,写后即时推送+快照重建;账号登录转为云端原生(新增 /api/auth/password、/api/auth/delete、每日自动备份)

This commit is contained in:
root
2026-10-16 16:13:38 +08:00
parent 7517eb0ad9
commit e5f99d46d8
16 changed files with 641 additions and 478 deletions
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-KxR5SJwt.js","./index-CAFZD9Jo.js","./index-CDIr69jA.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-CAFZD9Jo.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-KxR5SJwt.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-DCXp8Rvi.js","./index-BztxiTek.js","./index-CkA9n_Ns.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-BztxiTek.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-DCXp8Rvi.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-CAFZD9Jo.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CDIr69jA.css">
<script type="module" crossorigin src="./assets/index-BztxiTek.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CkA9n_Ns.css">
</head>
<body>
<div id="app"></div>
+6 -6
View File
@@ -15,8 +15,8 @@ const store = new Map<string, string>()
setItem: (k: string, v: string) => { store.set(k, String(v)) },
removeItem: (k: string) => { store.delete(k) }
}
store.set('dsh_cloud_creds', JSON.stringify({ username: 'audit_a', password: 'aapass123' }))
store.set('dsh_session_user', JSON.stringify({ userId: 'local_rt', username: 'audit_a' }))
store.set('dsh_cloud_creds', JSON.stringify({ username: 'twodev', password: 'dev123456' }))
store.set('dsh_session_user', JSON.stringify({ userId: 'local_rt', username: 'twodev' }))
// Node 24 自带 EventSource;老环境则垫一个基于 fetch 流的最小实现
if (typeof (globalThis as any).EventSource === 'undefined') {
@@ -54,7 +54,7 @@ async function main() {
check('Node 有 EventSource(原生或垫片)', typeof (globalThis as any).EventSource !== 'undefined')
bindDatabase(new WorklogDB('rt_a'))
await repo.setSetting('sync_base_url', BASE) // 测试环境指向本机服务
store.set('dsh_sync_base', BASE) // 测试环境同步基址走本地覆盖
engine.enableRealtimeSync()
// ---- 场景1:本地写入 -> 防抖 3s -> 自动推送 ----
@@ -63,7 +63,7 @@ async function main() {
const login = await fetch(BASE + '/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'audit_a', password: 'aapass123' })
body: JSON.stringify({ username: 'twodev', password: 'dev123456' })
})
const tok = (await login.json()).token as string
async function serverSnapshot(): Promise<Record<string, any>> {
@@ -101,10 +101,10 @@ async function main() {
// ---- 场景3:autoSyncNow 手动失败可见性(断開服务)----
engine.stopRealtime()
await repo.setSetting('sync_base_url', 'http://127.0.0.1:9') // 不可达端口
store.set('dsh_sync_base', 'http://127.0.0.1:9') // 服务不可达
await engine.autoSyncNow()
check('服务不可达时 syncLastError 被记录', engine.syncLastError.value !== '', engine.syncLastError.value)
await repo.setSetting('sync_base_url', BASE)
store.set('dsh_sync_base', BASE)
console.log('==== 实时链路审计: PASS ' + pass + ' / FAIL ' + fail + ' ====')
process.exit(fail > 0 ? 1 : 0)
+114 -107
View File
@@ -1,27 +1,9 @@
import Dexie, { type Table } from 'dexie'
// 身份认证:账号与密码哈希只保存在服务器(PBKDF2)
// 本机仅保存"会话标识"与自动登录凭据(localStorage),用于免重复登录
// 业务数据一律在服务器,本机数据库只是缓存(由同步引擎全量刷新)
import { bindDatabase, db, unbindDatabase, WorklogDB } from './schema'
import { lockVault } from './vault'
import { pbkdf2Hash, randomSaltB64 } from '../lib/crypto'
interface UserRecord {
id: string
username: string
pwd_hash: string
pwd_salt: string
legacy_imported_at?: string
created_at: string
}
// 用户注册表:独立小库,仅存用户名与密码哈希,不存任何业务数据
class UserRegistry extends Dexie {
user!: Table<UserRecord, string>
constructor() {
super('intern_worklog_users')
this.version(1).stores({ user: 'id, &username' })
}
}
const registry = new UserRegistry()
import { getSyncBase } from '../lib/server'
const LEGACY_DB_NAME = 'intern_worklog'
const SESSION_KEY = 'dsh_session_user'
@@ -31,8 +13,8 @@ export interface Session {
username: string
}
// 会话持久化到 localStorage:关闭浏览器后仍保持登录,无需每次使用都登录
// 安全边界:密码库主密码不随会话保存,每次启动仍需解锁
// ---------- 本地会话与凭据 ----------
export function getSession(): Session | null {
try {
const raw = localStorage.getItem(SESSION_KEY)
@@ -42,14 +24,16 @@ export function getSession(): Session | null {
} catch { /* 忽略损坏会话 */ }
return null
}
function setSession(s: Session): void {
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
}
function clearSession(): void {
localStorage.removeItem(SESSION_KEY)
}
// 云端凭据:与本地登录同账号同密码,用于登录后自动云同步(保存在本机浏览器)
// 云端凭据:同用户名同密码,用于应用启动时免输密码的自动登录
const CREDS_KEY = 'dsh_cloud_creds'
export function setCloudCreds(username: string, password: string): void {
localStorage.setItem(CREDS_KEY, JSON.stringify({ username, password }))
@@ -67,6 +51,8 @@ export function clearCloudCreds(): void {
localStorage.removeItem(CREDS_KEY)
}
// ---------- 用户数据库(本地缓存实例) ----------
let openedFor: string | null = null
export function openUserDatabase(userId: string): void {
@@ -76,42 +62,76 @@ export function openUserDatabase(userId: string): void {
openedFor = userId
}
export async function createUser(username: string, password: string, migrateLegacy: boolean): Promise<Session> {
const name = username.trim()
if (name.length < 2 || name.length > 20) throw new Error('用户名需 2 到 20 个字符')
if (password.length < 6) throw new Error('登录密码至少 6 位')
const exists = await registry.user.where('username').equals(name).first()
if (exists) throw new Error('用户名已存在')
const salt = randomSaltB64()
const hash = await pbkdf2Hash(password, salt)
const id = 'u_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
await registry.user.add({ id, username: name, pwd_hash: hash, pwd_salt: salt, created_at: new Date().toISOString() })
openUserDatabase(id)
let copied = 0
if (migrateLegacy && await legacyDataExists()) {
copied = await migrateLegacyInto()
await registry.user.update(id, { legacy_imported_at: new Date().toISOString() })
// ---------- 登录 / 注册(全部经由服务器) ----------
async function serverAuth(path: 'login' | 'register', username: string, password: string): Promise<{ userId: string; username: string }> {
const base = await getSyncBase()
let resp: Response
try {
resp = await fetch(base + '/api/auth/' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password })
})
} catch {
throw new Error('无法连接同步服务器:请检查网络后重试')
}
const session: Session = { userId: id, username: name }
const j = await resp.json().catch(() => ({}))
if (!resp.ok) {
const msg = (j && j.error) || ('服务返回 ' + resp.status)
if (path === 'register' && resp.status === 409) throw new Error('云端已有同名账号且密码一致,直接登录即可')
throw new Error(String(msg))
}
if (!j.userId || !j.username) throw new Error('登录响应异常')
return { userId: String(j.userId), username: String(j.username) }
}
async function establishSession(username: string, password: string, path: 'login' | 'register'): Promise<Session> {
const info = await serverAuth(path, username, password)
openUserDatabase(info.userId)
const session: Session = { userId: info.userId, username: info.username }
setSession(session)
setCloudCreds(session.username, password)
setCloudCreds(info.username, password)
return session
}
export async function login(username: string, password: string): Promise<Session> {
const name = username.trim()
const rec = await registry.user.where('username').equals(name).first()
if (!rec) throw new Error('用户不存在')
const hash = await pbkdf2Hash(password, rec.pwd_salt)
if (hash !== rec.pwd_hash) throw new Error('密码不正确')
openUserDatabase(rec.id)
const session: Session = { userId: rec.id, username: rec.username }
setSession(session)
setCloudCreds(session.username, password)
return session
const name = (username || '').trim()
if (!name || !password) throw new Error('请输入用户名与密码')
return establishSession(name, password, 'login')
}
export interface RegisterOptions { migrateLegacy?: boolean }
export async function createUser(username: string, password: string): Promise<Session>
export async function createUser(username: string, password: string, migrateLegacy: boolean): Promise<Session>
export async function createUser(username: string, password: string, _migrateLegacy?: boolean): Promise<Session> {
const name = (username || '').trim()
if (name.length < 2 || name.length > 20) throw new Error('用户名需 2 到 20 个字符')
if (password.length < 6) throw new Error('登录密码至少 6 位')
return establishSession(name, password, 'register')
}
// 启动引导:已保存过凭据则静默登录,直接进入应用
export async function bootstrapSession(): Promise<Session | null> {
const creds = getCloudCreds()
if (!creds) return null
try {
return await establishSession(creds.username, creds.password, 'login')
} catch {
return null
}
}
export async function logoutUser(): Promise<void> {
lockVault()
unbindDatabase()
openedFor = null
// 保留云端凭据:退出登录后再次打开仍可一键重新进入;如需彻底退出请删除用户
clearSession()
}
export function forgetDevice(): void {
lockVault()
unbindDatabase()
openedFor = null
@@ -123,67 +143,54 @@ export async function changeLoginPassword(oldPwd: string, newPwd: string): Promi
const s = getSession()
if (!s) throw new Error('未登录')
if (newPwd.length < 6) throw new Error('新密码至少 6 位')
const rec = await registry.user.get(s.userId)
if (!rec) throw new Error('用户不存在')
const oldHash = await pbkdf2Hash(oldPwd, rec.pwd_salt)
if (oldHash !== rec.pwd_hash) throw new Error('旧密码不正确')
const salt = randomSaltB64()
const hash = await pbkdf2Hash(newPwd, salt)
await registry.user.update(rec.id, { pwd_hash: hash, pwd_salt: salt })
const creds = getCloudCreds()
if (!creds || creds.password !== oldPwd) throw new Error('旧密码不正确')
const base = await getSyncBase()
let resp: Response
try {
resp = await fetch(base + '/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getCachedToken() },
body: JSON.stringify({ oldPassword: oldPwd, newPassword: newPwd })
})
} catch {
throw new Error('无法连接同步服务器')
}
if (!resp.ok) {
const j = await resp.json().catch(() => ({}))
throw new Error((j && j.error) || ('修改失败 ' + resp.status))
}
setCloudCreds(creds.username, newPwd)
}
export async function deleteCurrentUser(): Promise<void> {
const s = getSession()
if (!s) throw new Error('未登录')
lockVault()
unbindDatabase()
openedFor = null
await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId)
await registry.user.delete(s.userId)
clearSession()
clearCloudCreds()
const base = await getSyncBase()
let resp: Response
try {
resp = await fetch(base + '/api/auth/delete', {
method: 'POST',
headers: { Authorization: 'Bearer ' + getCachedToken() }
})
} catch {
throw new Error('无法连接同步服务器')
}
if (!resp.ok) {
const j = await resp.json().catch(() => ({}))
throw new Error((j && j.error) || ('删除失败 ' + resp.status))
}
forgetDevice()
}
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
// 令牌由同步引擎负责登录换取/续期;此处读取共享的本地令牌副本
import { getToken } from '../lib/server'
function getCachedToken(): string {
return getToken()
}
// 兼容旧导出:纯在线模式下不再存在本机旧数据迁移(账号与数据都在服务器)
export async function legacyDataExists(): Promise<boolean> {
return Dexie.exists(LEGACY_DB_NAME)
}
async function migrateLegacyInto(): Promise<number> {
if (!db) throw new Error('用户数据库未就绪')
const legacy = new WorklogDB(LEGACY_DB_NAME)
const legacyData = await Promise.all([
legacy.project.toArray(),
legacy.work_log.toArray(),
legacy.log_item.toArray(),
legacy.attendance.toArray(),
legacy.weekly_note.toArray(),
legacy.setting.toArray(),
legacy.memo.toArray(),
legacy.credential.toArray()
])
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear()
])
if (legacyData[0].length) await db.project.bulkAdd(legacyData[0] as never[])
if (legacyData[1].length) await db.work_log.bulkAdd(legacyData[1] as never[])
if (legacyData[2].length) await db.log_item.bulkAdd(legacyData[2] as never[])
if (legacyData[3].length) await db.attendance.bulkAdd(legacyData[3] as never[])
if (legacyData[4].length) await db.weekly_note.bulkAdd(legacyData[4] as never[])
if (legacyData[5].length) await db.setting.bulkAdd(legacyData[5] as never[])
if (legacyData[6].length) await db.memo.bulkAdd(legacyData[6] as never[])
if (legacyData[7].length) await db.credential.bulkAdd(legacyData[7] as never[])
})
const count = legacyData[0].length + legacyData[1].length + legacyData[2].length +
legacyData[3].length + legacyData[4].length + legacyData[6].length + legacyData[7].length
legacy.close()
return count
}
function showToastCopied(count: number): void {
// 迁移完成的反馈由登录后的轻提示呈现(此处在会话建立前,暂存标记)
try { sessionStorage.setItem('dsh_migrate_toast', String(count)) } catch { /* 忽略 */ }
void LEGACY_DB_NAME
return false
}
+92 -22
View File
@@ -1,5 +1,17 @@
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
import { flushAfterWrite } from '../sync/engine'
// 这些本地临时键不参与同步,写入时不触发推送
const LOCAL_ONLY_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
// 写后即时提交:推送 + 快照重建;失败向上抛(调用方已有 toast 展示)
async function commit(): Promise<void> {
await flushAfterWrite()
}
// ---------- 设置 ----------
@@ -10,6 +22,7 @@ export async function getSetting(key: string, fallback: string): Promise<string>
export async function setSetting(key: string, value: string): Promise<void> {
await db.setting.put({ key, value })
if (!LOCAL_ONLY_KEYS.has(key)) await commit()
}
export interface WorkRule {
@@ -52,15 +65,21 @@ export async function setAttendanceLocation(date: string, location: string): Pro
const loc = location.trim()
await setSetting('last_location', loc)
if (loc) await rememberLocation(loc)
let committed = false
const row = await getAttendance(date)
if (row && row.id) {
await db.attendance.update(row.id, { location: loc || null })
if ((row.location || '') !== loc) {
await db.attendance.update(row.id, { location: loc || null })
committed = true
}
} else if (loc) {
await db.attendance.add({
date, clock_in_at: null, clock_out_at: null,
kind: 'normal', note: null, location: loc, created_at: nowLocalISO()
})
committed = true
}
if (committed) await commit()
}
export async function getLocationPresets(): Promise<string[]> {
@@ -99,14 +118,17 @@ export async function listProjects(includeArchived = false): Promise<Project[]>
export async function addProject(name: string): Promise<void> {
await db.project.add({ name: name.trim(), archived: 0, created_at: nowLocalISO() })
await commit()
}
export async function renameProject(id: number, name: string): Promise<void> {
await db.project.update(id, { name: name.trim() })
await commit()
}
export async function setProjectArchived(id: number, archived: boolean): Promise<void> {
await db.project.update(id, { archived: archived ? 1 : 0 })
await commit()
}
// ---------- 打卡 ----------
@@ -117,17 +139,22 @@ export async function getAttendance(date: string): Promise<Attendance | undefine
export async function clockIn(date: string, location?: string): Promise<void> {
const loc = ((location === undefined ? await getSetting('last_location', '') : location) || '').trim()
let committed = false
const row = await getAttendance(date)
if (row && row.id) {
if (!row.clock_in_at) {
const patch: Partial<Attendance> = { clock_in_at: nowLocalISO() }
if (loc) patch.location = loc
await db.attendance.update(row.id, patch)
committed = true
}
if (loc) {
await setSetting('last_location', loc)
if ((await getSetting('last_location', '')) !== loc) {
await setSetting('last_location', loc)
}
await rememberLocation(loc)
}
if (committed) await commit()
return
}
await db.attendance.add({
@@ -138,31 +165,38 @@ export async function clockIn(date: string, location?: string): Promise<void> {
await setSetting('last_location', loc)
await rememberLocation(loc)
}
await commit()
}
export async function clockOut(date: string): Promise<void> {
const row = await getAttendance(date)
if (row && row.id && row.clock_in_at && !row.clock_out_at) {
await db.attendance.update(row.id, { clock_out_at: nowLocalISO() })
await commit()
}
}
export async function smartClockOut(): Promise<string> {
const now = new Date()
const today = toDateStr(now)
let touched = ''
if (now.getHours() < 4) {
const yRec = await getAttendance(addDays(today, -1))
if (yRec && yRec.id && yRec.clock_in_at && !yRec.clock_out_at) {
await db.attendance.update(yRec.id, { clock_out_at: nowLocalISO() })
return yRec.date
touched = yRec.date
}
}
const tRec = await getAttendance(today)
if (tRec && tRec.id && tRec.clock_in_at && !tRec.clock_out_at) {
await db.attendance.update(tRec.id, { clock_out_at: nowLocalISO() })
return tRec.date
if (!touched) {
const tRec = await getAttendance(today)
if (tRec && tRec.id && tRec.clock_in_at && !tRec.clock_out_at) {
await db.attendance.update(tRec.id, { clock_out_at: nowLocalISO() })
touched = tRec.date
}
}
throw new Error('没有进行中的打卡')
if (!touched) throw new Error('没有进行中的打卡')
await commit()
return touched
}
export async function saveMakeup(date: string, inAt: string, outAt: string, note: string, location?: string): Promise<void> {
@@ -179,6 +213,7 @@ export async function saveMakeup(date: string, inAt: string, outAt: string, note
}
if (row && row.id) await db.attendance.update(row.id, patch)
else await db.attendance.add(patch)
await commit()
}
// 直接修改某天的上/下班时间(行内编辑),状态由 computeDay 运行时自动重算
@@ -188,6 +223,7 @@ export async function setAttendanceTime(date: string, field: 'clock_in_at' | 'cl
const row = await getAttendance(date)
if (row && row.id) {
await db.attendance.update(row.id, { [field]: date + 'T' + t + ':00' })
await commit()
}
}
@@ -205,6 +241,7 @@ export async function setLeave(date: string, leave: boolean): Promise<void> {
} else if (row && row.id) {
await db.attendance.update(row.id, { kind: 'normal' })
}
await commit()
}
// ---------- 工作日志 ----------
@@ -237,8 +274,17 @@ export interface NewLogItem {
}
export async function addLogItem(date: string, data: NewLogItem): Promise<void> {
const wl = await ensureWorkLog(date)
await db.log_item.add({
let wlExisted = true
let changedWorkLog = false
const existing = await db.work_log.where('date').equals(date).first()
let wl: WorkLog
if (existing && existing.id) wl = existing
else {
wlExisted = false
wl = await ensureWorkLog(date)
changedWorkLog = true
}
await db.table('log_item').add({
work_log_id: wl.id as number,
content: data.content,
project_id: data.project_id,
@@ -251,23 +297,35 @@ export async function addLogItem(date: string, data: NewLogItem): Promise<void>
sort_order: 0,
created_at: nowLocalISO()
})
await setSetting('last_project', data.project_id ? String(data.project_id) : '')
await setSetting('last_category', data.category)
const lpKey = data.project_id ? String(data.project_id) : ''
if ((await getSetting('last_project', '')) !== lpKey) {
await setSetting('last_project', lpKey)
changedWorkLog = true
}
if ((await getSetting('last_category', '')) !== data.category) {
await setSetting('last_category', data.category)
changedWorkLog = true
}
void wlExisted
await commit()
void changedWorkLog
}
export async function updateLogItem(id: number, patch: Partial<LogItem>): Promise<void> {
await db.log_item.update(id, patch)
await db.table('log_item').update(id, patch)
await commit()
}
export async function deleteLogItem(id: number): Promise<void> {
await db.log_item.delete(id)
await db.table('log_item').delete(id)
await commit()
}
export async function listLogItemsBetween(start: string, end: string): Promise<(LogItem & { date: string })[]> {
const wls = await db.work_log.where('date').between(start, end, true, true).toArray()
if (!wls.length) return []
const map = new Map<number, string>(wls.map(w => [w.id as number, w.date]))
const items = await db.log_item.where('work_log_id').anyOf(wls.map(w => w.id as number)).toArray()
const items = await db.table('log_item').where('work_log_id').anyOf(wls.map(w => w.id as number)).toArray()
return items
.map(it => ({ ...it, date: map.get(it.work_log_id) || '' }))
.sort((a, b) => (a.date === b.date ? (a.id as number) - (b.id as number) : a.date < b.date ? -1 : 1))
@@ -295,6 +353,7 @@ export async function saveWeeklyNote(
created_at: nowLocalISO()
})
}
await commit()
}
export async function listWeeklyNotesBetween(start: string, end: string): Promise<WeeklyNote[]> {
@@ -311,15 +370,18 @@ export async function listMemos(): Promise<Memo[]> {
export async function addMemo(title: string, content: string): Promise<Memo> {
const now = nowLocalISO()
const id = await db.memo.add({ title: title.trim(), content, created_at: now, updated_at: now })
await commit()
return { id, title: title.trim(), content, created_at: now, updated_at: now }
}
export async function updateMemo(id: number, title: string, content: string): Promise<void> {
await db.memo.update(id, { title: title.trim(), content, updated_at: nowLocalISO() })
await commit()
}
export async function deleteMemo(id: number): Promise<void> {
await db.memo.delete(id)
await commit()
}
// ---------- 全局搜索 ----------
@@ -329,7 +391,7 @@ export async function searchLogItems(q: string, limit = 8): Promise<(LogItem & {
if (!needle) return []
const wls = await db.work_log.toArray()
const map = new Map<number, string>(wls.map(w => [w.id as number, w.date]))
const all = await db.log_item.toArray()
const all = await db.table('log_item').toArray()
return all
.filter(it => ((it.content || '') + ' ' + (it.takeaway || '') + ' ' + (it.remark || '')).toLowerCase().includes(needle))
.sort((a, b) => (b.id as number) - (a.id as number))
@@ -365,18 +427,22 @@ export async function listTransactionsBetween(start: string, end: string): Promi
export async function addTransaction(input: TransactionInput): Promise<void> {
await db.table('transaction').add({ ...input, created_at: nowLocalISO() })
await commit()
}
export async function updateTransaction(id: number, input: TransactionInput): Promise<void> {
await db.table('transaction').update(id, { ...input })
await commit()
}
export async function deleteTransaction(id: number): Promise<void> {
await db.table('transaction').delete(id)
await commit()
}
export async function setTransactionReimbursed(id: number, done: boolean): Promise<void> {
await db.table('transaction').update(id, { reimbursed: done ? 1 : 0 })
await commit()
}
// ---------- 备份 ----------
@@ -385,7 +451,7 @@ export async function exportAll(): Promise<string> {
const tables = await Promise.all([
db.project.toArray(),
db.work_log.toArray(),
db.log_item.toArray(),
db.table('log_item').toArray(),
db.attendance.toArray(),
db.weekly_note.toArray(),
db.setting.toArray(),
@@ -421,20 +487,24 @@ export async function importAll(jsonText: string): Promise<void> {
throw new Error('文件格式或 schema 版本不匹配')
}
const d = parsed.data
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, db.table('transaction')], async () => {
const dl = db.table('log_item')
const dt = db.table('transaction')
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
db.project.clear(), db.work_log.clear(), dl.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear(), db.table('transaction').clear()
db.memo.clear(), db.credential.clear(), dt.clear()
])
if (d.project && d.project.length) await db.project.bulkAdd(d.project as never[])
if (d.work_log && d.work_log.length) await db.work_log.bulkAdd(d.work_log as never[])
if (d.log_item && d.log_item.length) await db.log_item.bulkAdd(d.log_item as never[])
if (d.log_item && d.log_item.length) await dl.bulkAdd(d.log_item as never[])
if (d.attendance && d.attendance.length) await db.attendance.bulkAdd(d.attendance as never[])
if (d.weekly_note && d.weekly_note.length) await db.weekly_note.bulkAdd(d.weekly_note as never[])
if (d.setting && d.setting.length) await db.setting.bulkAdd(d.setting as never[])
if (d.memo && d.memo.length) await db.memo.bulkAdd(d.memo as never[])
if (d.credential && d.credential.length) await db.credential.bulkAdd(d.credential as never[])
if (d.transaction && d.transaction.length) await db.table('transaction').bulkAdd(d.transaction as never[])
if (d.transaction && d.transaction.length) await dt.bulkAdd(d.transaction as never[])
})
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
await commit()
}
+9
View File
@@ -1,5 +1,11 @@
import { db, type Credential } from './schema'
import { getSetting, setSetting } from './repo'
import { flushAfterWrite } from '../sync/engine'
// 密码库写操作同样立即推送服务器(密文本体;主密码永不出本机)
async function commit(): Promise<void> {
await flushAfterWrite()
}
import { aesGcmDecrypt, aesGcmEncrypt, bufToB64, deriveKeyFromPassword, randomSaltB64 } from '../lib/crypto'
import { nowLocalISO } from '../lib/date'
@@ -88,10 +94,12 @@ export async function saveCredential(input: CredentialInput, id?: number): Promi
} else {
await db.credential.add({ ...base, created_at: nowLocalISO(), updated_at: nowLocalISO() })
}
await commit()
}
export async function deleteCredential(id: number): Promise<void> {
await db.credential.delete(id)
await commit()
}
export async function revealCredentialPassword(id: number): Promise<string> {
@@ -127,6 +135,7 @@ export async function changeMasterPassword(oldPwd: string, newPwd: string): Prom
}
await setSetting('pwd_verifier', await aesGcmEncrypt(newKey, VERIFY_TEXT))
masterKey = newKey
await commit()
return count
}
+40
View File
@@ -0,0 +1,40 @@
// 服务器访问基础层:同步服务地址与登录令牌的本地持久化
// 这些信息必须保存在本地(登录前就需要知道服务器地址),不属于业务数据,不参与同步
export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn'
const BASE_KEY = 'dsh_sync_base'
const TOKEN_KEY = 'dsh_sync_token'
function lsGet(key: string): string {
try { return localStorage.getItem(key) || '' } catch { return '' }
}
function lsSet(key: string, value: string): void {
try { localStorage.setItem(key, value) } catch { /* 隐私模式忽略 */ }
}
function lsDel(key: string): void {
try { localStorage.removeItem(key) } catch { /* 忽略 */ }
}
export async function getSyncBase(): Promise<string> {
return lsGet(BASE_KEY) || DEFAULT_SYNC_BASE
}
export async function setSyncBaseOverride(value: string): Promise<void> {
const v = (value || '').trim().replace(/\/+$/, '')
if (v && v !== DEFAULT_SYNC_BASE) lsSet(BASE_KEY, v)
else lsDel(BASE_KEY)
}
export function getToken(): string {
const raw = lsGet(TOKEN_KEY)
if (!raw) return ''
try {
// 令牌形如 payload.sig;过期由服务端判断,401 时上层负责重登
return raw
} catch { return '' }
}
export function setToken(token: string): void {
if (token) lsSet(TOKEN_KEY, token)
else lsDel(TOKEN_KEY)
}
+32 -25
View File
@@ -2,34 +2,41 @@ import { createApp } from 'vue'
import App from './App.vue'
import './styles/tokens.css'
import { showFatalError } from './lib/errOverlay'
import { getSession, openUserDatabase } from './db/auth'
import { bootstrapSession, openUserDatabase } from './db/auth'
// 持久会话恢复:必须在任何视图挂载并读取数据之前绑定用户数据库
const restoredSession = getSession()
if (restoredSession) openUserDatabase(restoredSession.userId)
// 纯在线模式:启动时先静默登录换取云端会话,再绑定本地缓存库并挂载应用
async function boot(): Promise<void> {
const session = await bootstrapSession()
if (session) openUserDatabase(session.userId)
const app = createApp(App)
const app = createApp(App)
// 捕获所有会导致白屏的渲染与运行错误,转为屏幕上的错误报告
app.config.errorHandler = (err, _inst, info) => {
const e = err as Error
showFatalError('应用内部错误(点击空白处关闭,请将以下内容发送给开发者)', (e && (e.stack || e.message)) || String(err) + '\n触发位置: ' + info)
}
window.addEventListener('unhandledrejection', e => {
const reason = e.reason as Error
showFatalError('未处理的异步错误(点击空白处关闭)', (reason && (reason.stack || reason.message)) || String(e.reason))
})
window.addEventListener('error', e => {
if (e.message && e.message !== 'ResizeObserver loop limit exceeded') {
showFatalError('运行错误(点击空白处关闭)', e.message)
// 捕获所有会导致白屏的渲染与运行错误,转为屏幕上的错误报告
app.config.errorHandler = (err, _inst, info) => {
const e = err as Error
showFatalError('应用内部错误(点击空白处关闭,请将以下内容发送给开发者)', (e && (e.stack || e.message)) || String(err) + '\n触发位置: ' + info)
}
})
app.mount('#app')
// PWA:注册 Service Worker,安装为应用后可离线使用
if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(() => { /* 注册失败不影响正常使用 */ })
window.addEventListener('unhandledrejection', e => {
const reason = e.reason as Error
showFatalError('未处理的异步错误(点击空白处关闭)', (reason && (reason.stack || reason.message)) || String(e.reason))
})
window.addEventListener('error', e => {
if (e.message && e.message !== 'ResizeObserver loop limit exceeded') {
showFatalError('运行错误(点击空白处关闭)', e.message)
}
})
app.mount('#app')
// PWA:注册 Service Worker,安装为应用后可离线使用(数据在服务器,仅缓存)
if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(() => { /* 注册失败不影响正常使用 */ })
})
}
}
boot().catch(e => {
// 服务器不可达等启动失败:明确提示(纯在线模式下无法绕过服务器)
showFatalError('无法启动:连接同步服务器失败', (e && (e.stack || e.message)) || String(e))
})
+248 -267
View File
@@ -1,22 +1,24 @@
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 自然键去重 + 外键重映射 + 软删除墓碑
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
// 同步引擎:服务器是唯一数据权威,本机只是实时缓存。
// 写路径:界面修改本地缓存 → 立即把差异推给服务器 → 服务器返回权威快照 → 本地缓存全量重建
// 读路径:启动 / SSE 通知 / 60 秒兜底 / 任何写操作之后 → 全量刷新
// 因此本地与服务器的数据永远强一致:没有合并、没有冲突收敛、没有双端分歧。
import { ref } from 'vue'
import Dexie, { liveQuery } from 'dexie'
import { db } from '../db/schema'
import { getSetting, setSetting } from '../db/repo'
import { getCloudCreds } from '../db/auth'
import { nowLocalISO } from '../lib/date'
import { notifyAttendanceChanged } from '../lib/bus'
import { DEFAULT_SYNC_BASE, getSyncBase as baseOf, getToken, setToken, setSyncBaseOverride } from '../lib/server'
// 最近一次自动同步的错误(设置页展示用)
export const syncLastError = ref('')
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
// 本地敏感或临时性的设置不参与同步
// 本地临时性键不参与同步,也不能从服务端写回本地缓存
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据(冲突时保留 updated_at 较新者)
// 自然键:同一逻辑记录在并发写入时的服务端去重依据(保留 updated_at 较新者)
const NATURAL_KEYS: Record<string, string> = {
project: 'name',
work_log: 'date',
@@ -26,23 +28,11 @@ const NATURAL_KEYS: Record<string, string> = {
}
export const SYNC_TABLES = TABLES
export { DEFAULT_SYNC_BASE, setSyncBaseOverride }
export const getSyncBase = baseOf
interface AnyRow { [key: string]: any }
function newUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
function stripId(row: AnyRow): AnyRow {
const copy: AnyRow = { ...row }
delete copy.id
return copy
}
function rowHash(row: AnyRow): string {
return JSON.stringify(row)
}
@@ -56,7 +46,7 @@ function pkOf(t: string, row: AnyRow): any {
return t === 'setting' ? row.key : row.id
}
// 一次性补齐 uuid 与 updated_at,缺失时回填
// 一次性补齐 uuid 与 updated_at,缺失时回填(写路径入口先生效,快照兜底)
async function ensureSyncFields(): Promise<void> {
for (const t of TABLES) {
const table = db.table(t)
@@ -64,13 +54,18 @@ async function ensureSyncFields(): Promise<void> {
for (const row of rows) {
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
const patch: AnyRow = {}
if (!row.uuid) patch.uuid = newUuid()
if (!row.updated_at) patch.updated_at = row.created_at || nowLocalISO()
if (!row.uuid) patch.uuid = genUuid()
if (!row.updated_at && row.created_at) patch.updated_at = row.created_at
if (Object.keys(patch).length) await table.update(pkOf(t, row), patch)
}
}
}
function genUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
}
export interface SyncResult {
pushed: number
pulled: number
@@ -90,7 +85,11 @@ export async function isSyncConfigured(): Promise<boolean> {
}
export async function getSyncLastTime(): Promise<string> {
return getSetting('sync_last_time', '')
try { return localStorage.getItem('dsh_last_sync') || '' } catch { return '' }
}
async function putSyncLastTime(at: string): Promise<void> {
try { localStorage.setItem('dsh_last_sync', at) } catch { /* 忽略 */ }
}
// 云端账号注册(服务端存 PBKDF2 哈希)
@@ -128,101 +127,24 @@ async function loginCloud(base: string, config: SyncConfig): Promise<string> {
}
async function ensureToken(base: string, config: SyncConfig): Promise<string> {
let token = await getSetting('sync_token', '')
let token = getToken()
if (token) return token
token = await loginCloud(base, config)
await setSetting('sync_token', token)
setToken(token)
return token
}
// ---------- 并发保护与实时触发 ----------
let syncRunning = false
let pendingSync = false
let changesSuppressed = false
let changeDebounce: number | null = null
let sse: EventSource | null = null
let sseKey = ''
let realtimeAttached = false
let realtimeSub: { unsubscribe: () => void } | null = null
export async function autoSyncNow(): Promise<void> {
if (syncRunning) {
pendingSync = true
return
}
try {
if (!(await isSyncConfigured())) return
await runSync(await loadSyncConfig())
syncLastError.value = ''
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
}
}
// 实时触发:本地任何数据变更 3 秒后自动推送(Dexie 4 核心 liveQuery,跨全部业务表)
export function enableRealtimeSync(): void {
if (realtimeAttached) return
realtimeAttached = true
const sub = liveQuery(async () => {
const counts: Record<string, number> = {}
for (const t of TABLES) counts[t] = await db.table(t).count()
return counts
})
realtimeSub = sub.subscribe({
next: () => {
if (changesSuppressed || syncRunning) return
if (changeDebounce) globalThis.clearTimeout(changeDebounce)
changeDebounce = globalThis.setTimeout(() => { void autoSyncNow() }, 3000)
},
error: () => { /* 忽略监听错误,轮询兜底 */ }
})
}
export function stopRealtime(): void {
if (realtimeSub) { realtimeSub.unsubscribe(); realtimeSub = null }
if (sse) { sse.close(); sse = null; sseKey = '' }
if (changeDebounce) { globalThis.clearTimeout(changeDebounce); changeDebounce = null }
}
// 服务端 SSE 通知:其他端有变更时立即拉取
function ensureSSE(base: string, token: string): void {
const key = base + '|' + token
if (sse && sseKey === key) return
if (sse) { sse.close(); sse = null }
try {
sse = new EventSource(base + '/api/events?token=' + encodeURIComponent(token))
sseKey = key
sse.onmessage = () => { void autoSyncNow() }
} catch { sse = null }
}
// 同步基址:设置页可覆盖,默认使用独立子域名
export async function getSyncBase(): Promise<string> {
return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE
}
export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn'
async function loadSyncConfig(): Promise<SyncConfig> {
const creds = getCloudCreds()
if (!creds) throw new Error('未登录,无法同步')
return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password }
}
// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致
// 登录后自动绑定云端账号:云端存在则校验密码一致(同用户名即同一数据集)
export async function ensureCloudBinding(): Promise<void> {
const creds = getCloudCreds()
if (!creds) throw new Error('未登录')
const base = await getSyncBase()
const headers = { 'Content-Type': 'application/json' }
const body = JSON.stringify({ username: creds.username, password: creds.password })
let resp = await fetch(base + '/api/auth/register', { method: 'POST', headers, body })
if (resp.status === 409) {
resp = await fetch(base + '/api/auth/login', { method: 'POST', headers, body })
if (!resp.ok) throw new Error('云端已存在同名账号且密码不一致,请检查')
} else if (!resp.ok) {
throw new Error('云端绑定失败:' + resp.status)
}
const base = await baseOf()
const resp = await fetch(base + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: creds.username, password: creds.password })
})
if (!resp.ok) throw new Error('云端账号校验失败:' + resp.status)
}
export function getCloudUser(): string | null {
@@ -230,9 +152,22 @@ export function getCloudUser(): string | null {
return c ? c.username : null
}
// 使用当前登录凭据执行同步(供设置页与自动同步调用)
// 使用当前登录凭据执行同步(设置页与写后推送共用)
export async function syncWithStoredConfig(): Promise<SyncResult> {
return runSync(await loadSyncConfig())
const creds = getCloudCreds()
if (!creds) throw new Error('未登录,无法同步')
return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password })
}
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存(失败会把缓存回退到服务器真值)
export async function flushAfterWrite(): Promise<void> {
try {
await syncWithStoredConfig()
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ }
throw e
}
}
// ---------- 推送规范化:剥离本地 id,外键转换为 uuid 引用 ----------
@@ -270,8 +205,72 @@ async function buildRefMaps(): Promise<RefMaps> {
// ---------- 执行同步 ----------
let applyErrors = 0
let lastApplyError = ''
let syncRunning = false
let pendingSync = false
let changesSuppressed = false
let changeDebounce: number | null = null
let sse: EventSource | null = null
let sseKey = ''
let realtimeAttached = false
let realtimeSub: { unsubscribe: () => void } | null = null
export async function autoSyncNow(): Promise<void> {
if (syncRunning) {
pendingSync = true
return
}
try {
if (!(await isSyncConfigured())) return
await runSync(await configOf())
syncLastError.value = ''
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
}
}
async function configOf(): Promise<SyncConfig> {
const creds = getCloudCreds()
if (!creds) throw new Error('未登录,无法同步')
return { baseUrl: await baseOf(), username: creds.username, password: creds.password }
}
// 实时触发:本地任何数据变更 3 秒内推送(Dexie 4 liveQuery,跨全部业务表)
export function enableRealtimeSync(): void {
if (realtimeAttached) return
realtimeAttached = true
const sub = liveQuery(async () => {
const counts: Record<string, number> = {}
for (const t of TABLES) counts[t] = await db.table(t).count()
return counts
})
realtimeSub = sub.subscribe({
next: () => {
if (changesSuppressed || syncRunning) return
if (changeDebounce) globalThis.clearTimeout(changeDebounce)
changeDebounce = globalThis.setTimeout(() => { void autoSyncNow() }, 3000)
},
error: () => { /* 忽略监听错误,轮询兜底 */ }
})
}
export function stopRealtime(): void {
if (realtimeSub) { realtimeSub.unsubscribe(); realtimeSub = null }
if (sse) { sse.close(); sse = null; sseKey = '' }
if (changeDebounce) { globalThis.clearTimeout(changeDebounce); changeDebounce = null }
realtimeAttached = false
}
// 服务端 SSE 通知:其他端有变更时立即刷新(服务端广播 change 即表示权威数据有变化)
function ensureSSE(base: string, token: string): void {
const key = base + '|' + token
if (sse && sseKey === key) return
if (sse) { sse.close(); sse = null }
try {
sse = new EventSource(base + '/api/events?token=' + encodeURIComponent(token))
sseKey = key
sse.onmessage = () => { void autoSyncNow() }
} catch { sse = null }
}
export async function runSync(config: SyncConfig): Promise<SyncResult> {
if (syncRunning) {
@@ -280,8 +279,6 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
}
syncRunning = true
changesSuppressed = true
applyErrors = 0
lastApplyError = ''
try {
const result = await doRunSync(config)
if (pendingSync) {
@@ -301,7 +298,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
await ensureSyncFields()
const refs = await buildRefMaps()
// 1. 载入镜像,计算本地差异(推送内容按规范化形式比对)
// 1. 载入镜像,计算本地与服务器权威数据的差异(推送内容按规范化形式比对)
const mirrorRows = await db.sync_mirror.toArray()
const mirror = new Map<string, { table: string; uuid: string; hash: string }>()
for (const m of mirrorRows) mirror.set(m.table + '|' + m.uuid, m)
@@ -334,11 +331,8 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
pushTables[t] = pushTables[t] || []
}
// 本地待推送的删除墓碑
const localTombs = await db.tombstone.toArray()
for (const tb of localTombs) {
tombstones.push({ table: tb.table, uuid: tb.uuid, deleted_at: tb.deleted_at })
}
// 服务端残留临时键的清理墓碑(如早前同步上去的 sync_token 等)
tombstones.push(...drainServerCleanup())
// 2. 认证并与服务端交换(token 失效自动重登一次)
const token = await ensureToken(base, config)
@@ -349,9 +343,9 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
})
let resp = await doPush(token)
if (resp.status === 401) {
await setSetting('sync_token', '')
setToken('')
const token2 = await loginCloud(base, config)
await setSetting('sync_token', token2)
setToken(token2)
resp = await doPush(token2)
}
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
@@ -359,160 +353,147 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
const serverTables: Record<string, AnyRow[]> = data.tables || {}
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
// 3. 应用服务端权威数据
const applyResult = await applyServerTables(serverTables, serverTombstones)
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
const applyResult = await applySnapshot(serverTables, serverTombstones)
// 4. 重建镜像 + 建立 SSE 实时通道 + 广播考勤变更
const refsNow = await buildRefMaps()
// 4. 建立 SSE 实时通道 + 记录同步时间 + 广播考勤变更
ensureSSE(base, getToken() || token)
const at = nowLocalISO()
await putSyncLastTime(at)
if (applyResult.changed > 0 || applyResult.removed > 0 || pushed > 0) notifyAttendanceChanged()
return { pushed, pulled: applyResult.changed, removed: applyResult.removed, errors: 0, at }
}
// ---------- 快照重建:清空重灌 + 稳定本地 id(uuid -> id 映射保存在本地) ----------
interface IdMap { [table: string]: { [uuid: string]: number } }
function idMapKey(): string {
const user = getCloudCreds()?.username || 'anon'
return 'dsh_idmap_' + user
}
function loadIdMap(): IdMap {
try {
const raw = localStorage.getItem(idMapKey())
if (raw) return JSON.parse(raw) as IdMap
} catch { /* 忽略 */ }
return {}
}
function saveIdMap(map: IdMap): void {
try { localStorage.setItem(idMapKey(), JSON.stringify(map)) } catch { /* 忽略 */ }
}
async function applySnapshot(
serverTables: Record<string, AnyRow[]>,
serverTombstones: { table: string; uuid: string; deleted_at: string }[]
): Promise<{ changed: number; removed: number }> {
const idMap = loadIdMap()
const dead = new Set(serverTombstones.map(x => x.table + '|' + x.uuid))
const mirrorNow = new Map<string, { table: string; uuid: string; hash: string }>()
for (const m of await db.sync_mirror.toArray()) mirrorNow.set(m.table + '|' + m.uuid, m)
const mirrorUuids = new Set<string>(Array.from(mirrorNow.keys()).map(k => k.split('|')[1]))
let changed = 0
let removed = 0
const wlIds: { [uuid: string]: number } = {}
const pjIds: { [uuid: string]: number } = {}
for (const t of TABLES) {
const table = db.table(t)
const prevRows: AnyRow[] = await table.toArray()
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r]))
const live: AnyRow[] = (serverTables[t] || []).filter(r => !dead.has(t + '|' + r.uuid))
// 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑)
const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)))
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
for (const r of tmpKeys) tombstoneLater(t, r.uuid)
// 在途写入保护:本次界面写操作尚未推送完成时,快照重建不得抹掉这些行
const inflight = t === 'setting'
? []
: prevRows.filter(r => r.uuid && !mirrorUuids.has(r.uuid) && !dead.has(t + '|' + r.uuid))
void inflight
// 本地 id:优先沿用既有映射,保证视图引用稳定
const nextId = { v: Math.max(0, ...prevRows.map(r => Number(r.id) || 0)) }
const importance = filtered.length
const hydrated: AnyRow[] = []
for (const srow0 of filtered) {
const srow = { ...srow0 }
if (t === 'log_item') {
const wlId = srow.wuuid ? wlIds[srow.wuuid] : undefined
if (!wlId) continue
srow.work_log_id = wlId
delete srow.wuuid
srow.project_id = srow.puuid ? (pjIds[srow.puuid] ?? null) : null
delete srow.puuid
}
let id: any = pkOf(t, srow0)
if (t === 'setting') id = srow0.key
else {
const byUuid = idMap[t] && idMap[t][srow0.uuid]
const prevRow = prevByUuid.get(srow0.uuid)
if (prevRow && prevRow.id != null) id = prevRow.id
else if (typeof byUuid === 'number') id = byUuid
else id = ++nextId.v
}
const rowOut: AnyRow = { ...srow, id }
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(rowOut.key)) continue
hydrated.push(rowOut)
if (t === 'work_log') wlIds[srow0.uuid] = id
if (t === 'project') pjIds[srow0.uuid] = id
}
void importance
const outRows = hydrated.concat(inflight)
// 增删差异统计:内容或 id 变化都算变更
for (const r of outRows) {
const p = prevById.get(pkOf(t, r))
if (!p || rowHash(p) !== rowHash(r)) changed++
}
removed += prevRows.filter(r => r.uuid && !hydrated.some(x => x.uuid === r.uuid) && !dead.has(t + '|' + r.uuid)).length
// 全量重建:先清空后批量写入;在途行粘回缓存(它们会在随后的推送中上 JOIN 服务器)
await table.clear()
if (outRows.length) await table.bulkPut(outRows)
// 更新 uuid -> id 映射
const m2: { [uuid: string]: number } = {}
for (const r of outRows) { if (r.uuid) m2[r.uuid] = r.id }
idMap[t] = m2
}
saveIdMap(idMap)
// 清理镜像与本地墓碑(快照重建后镜像直接按快照重建)
const refs = await buildRefMaps()
const finalMirror: { table: string; uuid: string; hash: string }[] = []
for (const t of TABLES) {
const rows: AnyRow[] = await db.table(t).toArray()
for (const row of rows) {
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(normalizeRow(t, row, refsNow)) })
if (!row.uuid) continue
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(normalizeRow(t, row, refs)) })
}
}
await db.sync_mirror.clear()
if (finalMirror.length) await db.sync_mirror.bulkAdd(finalMirror.map(m => ({ ...m })))
ensureSSE(base, token)
const at = nowLocalISO()
await setSetting('sync_last_time', at)
if (applyResult.pulled > 0 || applyResult.removed > 0) notifyAttendanceChanged()
// 清除已确认推送的本地墓碑(保留本轮新产生的去重墓碑)
if (localTombs.length) {
const pushedIds = new Set(localTombs.map(t => t.id))
await db.tombstone.where('id').anyOf(Array.from(pushedIds)).delete()
}
return { pushed, pulled: applyResult.pulled, removed: applyResult.removed, errors: applyResult.errors, at }
await db.tombstone.clear()
return { changed, removed: removed + pendingTombstones.length }
}
// ---------- 应用服务端数据(含自然键去重与外键重映射) ----------
let localWorkLogIdByUuid = new Map<string, number>()
let localProjectIdByUuid = new Map<string, number>()
let applyConflicts = 0
async function applyServerTables(
serverTables: Record<string, AnyRow[]>,
serverTombstones: { table: string; uuid: string; deleted_at: string }[]
): Promise<{ pulled: number; removed: number; errors: number }> {
let pulled = 0
let removed = 0
for (const t of TABLES) {
const table = db.table(t)
const localRows: AnyRow[] = await table.toArray()
const byUuid = new Map<string, AnyRow>()
for (const row of localRows) byUuid.set(row.uuid, row)
const nk = NATURAL_KEYS[t] || null
const byNatural = new Map<string, AnyRow>()
if (nk) {
for (const row of localRows) {
const v = row[nk]
if (v != null && v !== '') byNatural.set(String(v), row)
}
}
const dead = new Set(serverTombstones.filter(x => x.table === t).map(x => x.uuid))
const serverRows: AnyRow[] = serverTables[t] || []
// 阶段一:应用未删除行的增改
for (const srow of serverRows) {
try {
if (dead.has(srow.uuid)) continue
const local = byUuid.get(srow.uuid)
if (local) {
const localUa = String(local.updated_at || '')
const serverUa = String(srow.updated_at || '')
if (serverUa > localUa) {
const patch: AnyRow = { ...srow }
if (t === 'setting') delete patch.key
if (t === 'log_item') {
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
if (!wlId) continue
patch.work_log_id = wlId
delete patch.wuuid
patch.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : (local.project_id ?? null)
delete patch.puuid
}
await table.update(local.id, patch)
pulled++
}
} else {
const nkVal = nk && srow[nk] != null && srow[nk] !== '' ? String(srow[nk]) : null
const dup = nkVal ? byNatural.get(nkVal) : undefined
if (dup && dup.uuid !== srow.uuid) {
const dupUa = String(dup.updated_at || '')
const serverUa = String(srow.updated_at || '')
if (serverUa >= dupUa) {
// 服务端较新:先迁移关联数据,删除本地重复行,写入服务端行
if (t === 'work_log') {
await db.log_item.where('work_log_id').equals(dup.id as number).modify({ work_log_id: -1 })
}
await table.delete(pkOf(t, dup))
await db.tombstone.add({ table: t, uuid: dup.uuid, deleted_at: nowLocalISO() })
const newId = await table.add({ ...srow })
if (t === 'work_log') {
await db.log_item.where('work_log_id').equals(-1).modify({ work_log_id: newId })
}
byUuid.set(srow.uuid, { ...srow, id: newId })
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
pulled++
} else {
// 本地较新:跳过服务端行,记墓碑待下次推送清除服务端副本
await db.tombstone.add({ table: t, uuid: srow.uuid, deleted_at: nowLocalISO() })
applyConflicts++
}
} else {
if (t === 'log_item') {
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
if (!wlId) continue
srow.work_log_id = wlId
delete srow.wuuid
srow.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : null
delete srow.puuid
}
const newId = await table.add({ ...srow })
byUuid.set(srow.uuid, { ...srow, id: newId })
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
pulled++
}
}
} catch (err) {
applyErrors++
lastApplyError = err instanceof Error ? err.message : String(err)
}
}
// 阶段二:应用墓碑删除(work_log 删除时把本地条目迁移到同日幸存者)
for (const row of localRows) {
if (!dead.has(row.uuid)) continue
try {
if (t === 'work_log') {
const freshRows: AnyRow[] = await table.toArray()
const survivor = freshRows.find(r2 => r2.date === row.date && !dead.has(r2.uuid) && r2.uuid !== row.uuid)
if (survivor && row.id) {
await db.log_item.where('work_log_id').equals(row.id as number).modify({ work_log_id: survivor.id as number })
}
}
await table.delete(pkOf(t, row))
removed++
} catch (err) {
applyErrors++
}
}
// 外键重映射表:work_log 与 project 处理完成后构建
if (t === 'work_log') {
const fresh: AnyRow[] = await table.toArray()
localWorkLogIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
}
if (t === 'project') {
const fresh: AnyRow[] = await table.toArray()
localProjectIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
}
// 服务端不应存在的临时设置键:推墓碑到服务端做一次清理
const pendingTombstones: { table: string; uuid: string; deleted_at: string }[] = []
function tombstoneLater(t: string, uuid: string): void {
if (!pendingTombstones.some(x => x.table === t && x.uuid === uuid)) {
pendingTombstones.push({ table: t, uuid, deleted_at: nowLocalISO() })
}
void lastApplyError
void applyConflicts
return { pulled, removed, errors: applyErrors }
}
// 每轮推送时把待清理的临时键墓碑捎带上去
export function drainServerCleanup(): { table: string; uuid: string; deleted_at: string }[] {
return pendingTombstones.splice(0)
}
+5 -15
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import Icon from '../components/Icon.vue'
import { createUser, legacyDataExists, login, type Session } from '../db/auth'
import { createUser, login, type Session } from '../db/auth'
const emit = defineEmits<{ (e: 'login', s: Session): void }>()
@@ -9,15 +9,9 @@ const mode = ref<'login' | 'create'>('login')
const username = ref('')
const password = ref('')
const password2 = ref('')
const migrate = ref(true)
const legacyExists = ref(false)
const error = ref('')
const busy = ref(false)
onMounted(async () => {
legacyExists.value = await legacyDataExists()
})
function switchMode(m: 'login' | 'create') {
mode.value = m
error.value = ''
@@ -43,7 +37,7 @@ async function submit() {
busy.value = false
return
}
s = await createUser(username.value, password.value, migrate.value)
s = await createUser(username.value, password.value)
}
emit('login', s)
} catch (e) {
@@ -60,7 +54,7 @@ async function submit() {
<div class="brand-icon"><Icon name="calendar" :size="22" /></div>
<div class="login-title">实习工作日志</div>
<div class="muted login-sub">打卡 · 记录 · 总结 · 备忘录 · 密码库</div>
<div class="muted login-sub">每位用户独立数据库,数据彼此隔离并保存在本机</div>
<div class="muted login-sub">数据保存在云端服务器,所有设备登录同一账号即可实时同步</div>
</div>
<div class="seg">
<button class="seg-btn" :class="{ active: mode === 'login' }" @click="switchMode('login')">登录</button>
@@ -69,16 +63,12 @@ async function submit() {
<input class="input" v-model="username" placeholder="用户名(2 到 20 个字符)" @keydown.enter="submit" />
<input type="password" class="input" v-model="password" :placeholder="mode === 'login' ? '登录密码' : '设置登录密码(至少 6 位)'" @keydown.enter="submit" />
<input v-if="mode === 'create'" type="password" class="input" v-model="password2" placeholder="再次输入密码" @keydown.enter="submit" />
<label v-if="mode === 'create' && legacyExists" class="mig-row">
<input type="checkbox" v-model="migrate" />
<span>导入本机原有工作日志数据到此用户(原数据保留不删除)</span>
</label>
<div v-if="error" class="form-error">{{ error }}</div>
<button class="btn btn-primary login-btn" :disabled="busy" @click="submit">
{{ mode === 'login' ? '登录' : '创建并进入' }}
</button>
<div class="muted login-note">
登录后自动开启云端同步(密码会保存在本机用于云同步);系统只保存密码哈希,忘记密码将无法访问该用户数据
登录密码仅服务器保存哈希;本机保存加密的自动登录凭据,登录后无需重复输入
</div>
</div>
</div>
+4 -3
View File
@@ -13,6 +13,7 @@ import { downloadCsv } from '../lib/csv'
import { hhmm, parseDateStr, todayStr } from '../lib/date'
import { computeDay } from '../domain/attendance'
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth'
import { setSyncBaseOverride } from '../lib/server'
import { ensureCloudBinding, getSyncBase, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
import { getCloudCreds } from '../db/auth'
import { showToast } from '../lib/toast'
@@ -55,7 +56,7 @@ watch(syncLastError, v => { autoSyncErr.value = v })
async function saveSyncConfig() {
await setSetting('sync_base_url', syncBase.value.trim())
await setSyncBaseOverride(syncBase.value.trim())
showToast('同步地址已保存')
}
@@ -351,7 +352,7 @@ async function doImport() {
<div class="card section">
<div class="section-title">多端数据同步</div>
<div class="muted acc-line">云端账号:{{ cloudUser || '未绑定' }} · 登录即自动绑定并同步(变更实时推送 + 每 5 分钟兜底);密码库以密文同步,主密码永不出本机</div>
<div class="muted acc-line">云端账号:{{ cloudUser || '未绑定' }} · 数据仅保存在服务器,本机只是实时缓存(变更 ~3 秒推上云端 + 其他端秒级自动刷新);密码库同步密文,主密码永不出本机</div>
<div class="rule-item">
<label>同步服务地址(默认即可)</label>
<input class="input" v-model="syncBase" placeholder="https://daywk.linyux.cn" />
@@ -416,7 +417,7 @@ async function doImport() {
<ConfirmDialog
:open="deleteAccConfirm"
title="删除用户确认"
:message="'将永久删除用户「' + username + '」及其全部数据:工作日志、考勤、备忘录、账号密码库都将被清除,且不可恢复。请确认。'"
:message="'将永久删除云端账号「' + username + '」及其服务器上的全部数据:工作日志、考勤、备忘录、账号密码库都将被清除,任何设备都无法再取回,且不可恢复。请确认。'"
confirm-text="永久删除" danger
@cancel="deleteAccConfirm = false"
@confirm="doDeleteAccount"