architecture: 数据唯一权威迁至服务器——本地降级为实时缓存,写后即时推送+快照重建;账号登录转为云端原生(新增 /api/auth/password、/api/auth/delete、每日自动备份)
This commit is contained in:
+2
-2
@@ -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};
|
||||
+26
File diff suppressed because one or more lines are too long
-26
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user