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