// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。 // 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器, // 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。 import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Goal, type PlanItem, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema' // 视图直接从 repo 导入这些类型:在此统一再导出 export type { Attendance, Credential, LogItem, Memo, Goal, PlanItem, Project, Transaction, WeeklyNote, WorkLog } 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 { await flushAfterWrite() } // ---------- 设置 ---------- export async function getSetting(key: string, fallback: string): Promise { const row = await db.setting.get(key) return row ? row.value : fallback } export async function setSetting(key: string, value: string): Promise { await db.setting.put({ key, value }) if (!LOCAL_ONLY_KEYS.has(key)) await commit() } export interface WorkRule { work_start: string work_end: string grace_minutes: number lunch_minutes: number remind_weekly: string } export async function getWorkRule(): Promise { const values = await Promise.all([ getSetting('work_start', '09:00'), getSetting('work_end', '18:00'), getSetting('grace_minutes', '10'), getSetting('lunch_minutes', '60'), getSetting('remind_weekly', 'friday') ]) return { work_start: values[0], work_end: values[1], grace_minutes: Number(values[2]) || 0, lunch_minutes: Number(values[3]) || 0, remind_weekly: values[4] } } export async function getLastPick(): Promise<{ project_id: number | null; category: string }> { const lp = await getSetting('last_project', '') const lc = await getSetting('last_category', 'dev') return { project_id: lp ? Number(lp) : null, category: lc || 'dev' } } export async function getLastLocation(): Promise { return getSetting('last_location', '') } // 记录某天的上班地点;当天尚无打卡记录时先建一条仅含地点的记录 export async function setAttendanceLocation(date: string, location: string): Promise { 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) { 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 { const raw = await getSetting('location_presets', '') if (!raw) return DEFAULT_LOCATIONS.slice() try { const arr = JSON.parse(raw) if (Array.isArray(arr)) { return arr.filter(x => typeof x === 'string' && x.trim()).map(x => (x as string).trim()) } } catch { /* 忽略损坏数据,回退默认 */ } return DEFAULT_LOCATIONS.slice() } export async function saveLocationPresets(list: string[]): Promise { const clean = Array.from(new Set(list.map(x => x.trim()).filter(Boolean))) await setSetting('location_presets', JSON.stringify(clean)) } // 自定义输入的地点自动加入常用列表,下次可直接选 export async function rememberLocation(loc: string): Promise { const v = loc.trim() if (!v) return const presets = await getLocationPresets() if (!presets.includes(v)) { await saveLocationPresets([...presets, v]) } } // ---------- 项目 ---------- export async function listProjects(includeArchived = false): Promise { const all = await db.project.orderBy('name').toArray() return includeArchived ? all : all.filter(p => p.archived !== 1) } export async function addProject(name: string): Promise { await db.project.add({ name: name.trim(), archived: 0, created_at: nowLocalISO() }) await commit() } export async function renameProject(id: number, name: string): Promise { await db.project.update(id, { name: name.trim() }) await commit() } export async function setProjectArchived(id: number, archived: boolean): Promise { await db.project.update(id, { archived: archived ? 1 : 0 }) await commit() } // ---------- 打卡 ---------- export async function getAttendance(date: string): Promise { return db.attendance.where('date').equals(date).first() } export async function clockIn(date: string, location?: string): Promise { 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 = { clock_in_at: nowLocalISO() } if (loc) patch.location = loc await db.attendance.update(row.id, patch) committed = true } if (loc) { if ((await getSetting('last_location', '')) !== loc) { await setSetting('last_location', loc) } await rememberLocation(loc) } if (committed) await commit() return } await db.attendance.add({ date, clock_in_at: nowLocalISO(), clock_out_at: null, kind: 'normal', note: null, location: loc || null, created_at: nowLocalISO() }) if (loc) { await setSetting('last_location', loc) await rememberLocation(loc) } await commit() } export async function clockOut(date: string): Promise { 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 { 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() }) touched = yRec.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 } } if (!touched) throw new Error('没有进行中的打卡') await commit() return touched } export async function saveMakeup(date: string, inAt: string, outAt: string, note: string, location?: string): Promise { if ((location || '').trim()) await rememberLocation(location as string) const row = await getAttendance(date) const patch = { date, clock_in_at: inAt, clock_out_at: outAt, kind: 'makeup', note: note || null, location: (location || '').trim() || null, created_at: nowLocalISO() } if (row && row.id) await db.attendance.update(row.id, patch) else await db.attendance.add(patch) await commit() } // 直接修改某天的上/下班时间(行内编辑),状态由 computeDay 运行时自动重算 export async function setAttendanceTime(date: string, field: 'clock_in_at' | 'clock_out_at', time: string): Promise { const t = (time || '').trim() if (!t) return const row = await getAttendance(date) if (row && row.id) { await db.attendance.update(row.id, { [field]: date + 'T' + t + ':00' }) await commit() } } export async function listAttendanceBetween(start: string, end: string): Promise { return db.attendance.where('date').between(start, end, true, true).toArray() } // 休假标记:标记时清空上下班时间,取消后恢复可打卡状态 export async function setLeave(date: string, leave: boolean): Promise { const row = await getAttendance(date) if (leave) { const base = { date, kind: 'leave', clock_in_at: null, clock_out_at: null, created_at: nowLocalISO() } if (row && row.id) await db.attendance.update(row.id, base) else await db.attendance.add({ ...base, note: null, location: null }) } else if (row && row.id) { await db.attendance.update(row.id, { kind: 'normal' }) } await commit() } // ---------- 工作日志 ---------- export async function ensureWorkLog(date: string): Promise { const found = await db.work_log.where('date').equals(date).first() if (found) return found const id = await db.work_log.add({ date, created_at: nowLocalISO() }) return { id, date, created_at: nowLocalISO() } } export async function getWorkLogByDate(date: string): Promise { return db.work_log.where('date').equals(date).first() } export async function listLogItemsByDate(date: string): Promise { const wl = await getWorkLogByDate(date) if (!wl || !wl.id) return [] const items = await db.log_item.where('work_log_id').equals(wl.id).toArray() return items.sort((a, b) => (b.id as number) - (a.id as number)) } export interface NewLogItem { content: string project_id: number | null category: string minutes: number | null takeaway: string | null remark: string | null } export async function addLogItem(date: string, data: NewLogItem): Promise { 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, category: data.category, minutes: data.minutes, is_milestone: 0, needs_followup: 0, takeaway: data.takeaway, remark: data.remark, sort_order: 0, created_at: nowLocalISO() }) 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): Promise { await db.table('log_item').update(id, patch) await commit() } export async function deleteLogItem(id: number): Promise { 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(wls.map(w => [w.id as number, w.date])) 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)) } // ---------- 周记 ---------- export async function getWeeklyNote(weekStart: string): Promise { return db.weekly_note.where('week_start').equals(weekStart).first() } export async function saveWeeklyNote( weekStart: string, patch: { highlights?: string | null; challenges?: string | null; plan_next?: string | null } ): Promise { const row = await getWeeklyNote(weekStart) if (row && row.id) { await db.weekly_note.update(row.id, patch) } else { await db.weekly_note.add({ week_start: weekStart, highlights: patch.highlights ?? null, challenges: patch.challenges ?? null, plan_next: patch.plan_next ?? null, created_at: nowLocalISO() }) } await commit() } export async function listWeeklyNotesBetween(start: string, end: string): Promise { return db.weekly_note.where('week_start').between(start, end, true, true).toArray() } // ---------- 计划模式 ---------- export interface PlanInput { date: string start_time: string end_time: string title: string kind: string note: string } export async function listPlansByDate(date: string): Promise { const rows = await db.plan.where('date').equals(date).toArray() return rows.sort((a, b) => (a.start_time < b.start_time ? -1 : 1)) } // 全局计时时钟面板:查找正在进行中的计时任务(跨日期,任意一天开始的都算) export async function listRunningPlan(): Promise { const rows = await db.plan.filter(p => !!p.timer_started_at).toArray() return rows.length ? rows[0] : null } export async function addPlan(input: PlanInput): Promise { const now = nowLocalISO() await db.plan.add({ date: input.date, start_time: input.start_time, end_time: input.end_time, title: input.title.trim(), kind: input.kind.trim() || '其他', note: input.note || null, status: 'todo', actual_minutes: 0, timer_started_at: null, uuid: '', created_at: now, updated_at: now } as never) await commit() } export async function updatePlan(id: number, patch: Partial): Promise { await db.plan.update(id, { ...patch, updated_at: nowLocalISO() }) await commit() } export async function deletePlan(id: number): Promise { await db.plan.delete(id) await commit() } // 打卡:完成 / 跳过 / 恢复 export async function setPlanStatus(id: number, status: 'todo' | 'done' | 'skip'): Promise { const row = await db.plan.get(id) if (!row) return if (status !== 'todo' && row.timer_started_at) { await stopPlanTimer(id) } await db.plan.update(id, { status, updated_at: nowLocalISO() }) await commit() } // 计时时钟:同一时刻只允许一个条目在计时,开始新计时会自动停止上一个 export async function startPlanTimer(id: number): Promise { const running = await db.plan.filter(p => !!p.timer_started_at).toArray() for (const p of running) { if (p.id !== id) await stopPlanTimer(p.id as number) } await db.plan.update(id, { timer_started_at: nowLocalISO(), updated_at: nowLocalISO() }) await commit() } export async function stopPlanTimer(id: number): Promise { const row = await db.plan.get(id) if (!row || !row.timer_started_at) return const elapsed = Math.max(0, Math.round((Date.now() - new Date(row.timer_started_at).getTime()) / 60000)) await db.plan.update(id, { actual_minutes: (row.actual_minutes || 0) + elapsed, timer_started_at: null, updated_at: nowLocalISO() }) await commit() } // ---------- 长期计划(目标) ---------- export interface GoalInput { title: string kind: string start_date: string end_date: string note: string } const MAX_MATERIALIZE_DAYS = 366 function eachDate(start: string, end: string): string[] { const out: string[] = [] let cur = start let guard = 0 while (cur <= end && guard < MAX_MATERIALIZE_DAYS) { out.push(cur) cur = addDays(cur, 1) guard++ } return out } export async function getGoalProgress(): Promise<{ goalId: number; done: number; total: number }[]> { const goals = await db.goal.toArray() const out: { goalId: number; done: number; total: number }[] = [] for (const g of goals) { const items = await db.plan.where('goal_id').equals(g.id as number).toArray() out.push({ goalId: g.id as number, done: items.filter(i => i.status === 'done').length, total: items.length }) } return out } export async function listGoals(): Promise { const rows = await db.goal.toArray() return rows.sort((a, b) => (a.start_date < b.start_date ? -1 : 1)) } // 新建长期计划并物化每一天的任务(落实每日打卡与计时) export async function addGoal(input: GoalInput): Promise { const now = nowLocalISO() const gid = await db.goal.add({ title: input.title.trim(), kind: input.kind.trim() || '其他', start_date: input.start_date, end_date: input.end_date, note: input.note || null, uuid: '', created_at: now, updated_at: now } as never) const goal = await db.goal.get(gid) const dates = eachDate(input.start_date, input.end_date) for (const d of dates) { await db.plan.add({ date: d, start_time: '08:00', end_time: '09:00', title: input.title.trim(), kind: input.kind.trim() || '其他', note: input.note || null, status: 'todo', actual_minutes: 0, timer_started_at: null, goal_id: gid, uuid: '', created_at: now, updated_at: now } as never) } void goal await commit() } // 编辑目标:标题/类别级联到其每日任务(已完成项不改动) export async function updateGoal(id: number, patch: Partial): Promise { const goal = await db.goal.get(id) if (!goal) return const next = { ...patch, updated_at: nowLocalISO() } await db.goal.update(id, next) const items = await db.plan.where('goal_id').equals(id).toArray() for (const it of items) { if (it.status === 'done') continue await db.plan.update(it.id as number, { ...(patch.title !== undefined ? { title: patch.title.trim() } : {}), ...(patch.kind !== undefined ? { kind: patch.kind } : {}), ...(patch.note !== undefined ? { note: patch.note || null } : {}), updated_at: nowLocalISO() }) } await commit() } // 删除目标:连同其每日任务一起删除(前端二次确认) export async function deleteGoal(id: number): Promise { const items = await db.plan.where('goal_id').equals(id).toArray() for (const it of items) await db.plan.delete(it.id as number) await db.goal.delete(id) await commit() } // ---------- 备忘录 ---------- export async function listMemos(): Promise { const rows = await db.memo.toArray() return rows.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1)) } export async function addMemo(title: string, content: string): Promise { 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 { await db.memo.update(id, { title: title.trim(), content, updated_at: nowLocalISO() }) await commit() } export async function deleteMemo(id: number): Promise { await db.memo.delete(id) await commit() } // ---------- 全局搜索 ---------- export async function searchLogItems(q: string, limit = 8): Promise<(LogItem & { date: string })[]> { const needle = q.trim().toLowerCase() if (!needle) return [] const wls = await db.work_log.toArray() const map = new Map(wls.map(w => [w.id as number, w.date])) 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)) .slice(0, limit) .map(it => ({ ...it, date: map.get(it.work_log_id) || '' })) } export async function searchMemos(q: string, limit = 4): Promise { const needle = q.trim().toLowerCase() if (!needle) return [] const all = await db.memo.toArray() return all .filter(m => ((m.title || '') + ' ' + (m.content || '')).toLowerCase().includes(needle)) .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1)) .slice(0, limit) } // ---------- 记账 ---------- export interface TransactionInput { type: string category: string amount: number date: string note: string reimbursable: number reimbursed: number } export async function listTransactionsBetween(start: string, end: string): Promise { return db.table('transaction').where('date').between(start, end, true, true).toArray() } export async function addTransaction(input: TransactionInput): Promise { await db.table('transaction').add({ ...input, created_at: nowLocalISO() }) await commit() } export async function updateTransaction(id: number, input: TransactionInput): Promise { await db.table('transaction').update(id, { ...input }) await commit() } export async function deleteTransaction(id: number): Promise { await db.table('transaction').delete(id) await commit() } export async function setTransactionReimbursed(id: number, done: boolean): Promise { await db.table('transaction').update(id, { reimbursed: done ? 1 : 0 }) await commit() } // 有账目的月份列表(YYYY-MM,倒序),供周期选择弹窗使用 export async function listTransactionMonths(): Promise { const all = await db.table('transaction').toArray() const set = new Set() for (const r of all) if (r && r.date) set.add(r.date.slice(0, 7)) return Array.from(set).sort((a, b) => (a < b ? 1 : -1)) } // 某日期之前的账目条数(判断「起点前」是否有数据) export async function countTransactionsBefore(date: string): Promise { return db.table('transaction').where('date').below(date).count() } // ---------- 备份 ---------- export async function exportAll(): Promise { const tables = await Promise.all([ db.project.toArray(), db.work_log.toArray(), db.table('log_item').toArray(), db.attendance.toArray(), db.weekly_note.toArray(), db.setting.toArray(), db.memo.toArray(), db.credential.toArray(), db.table('transaction').toArray(), db.plan.toArray(), db.goal.toArray() ]) return JSON.stringify({ schema_version: SCHEMA_VERSION, exported_at: nowLocalISO(), data: { project: tables[0], work_log: tables[1], log_item: tables[2], attendance: tables[3], weekly_note: tables[4], setting: tables[5], memo: tables[6], credential: tables[7], transaction: tables[8], plan: tables[9], goal: tables[10] } }, null, 2) } export async function importAll(jsonText: string): Promise { let parsed: { schema_version?: number; data?: Record } | null = null try { parsed = JSON.parse(jsonText) } catch { throw new Error('不是有效的 JSON 文件') } if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3 && parsed.schema_version !== 4 && parsed.schema_version !== 5) || !parsed.data) { throw new Error('文件格式或 schema 版本不匹配') } const d = parsed.data const dl = db.table('log_item') const dt = db.table('transaction') const dp = db.plan const dg = db.goal await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt, dg, dp], async () => { await Promise.all([ 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(), dt.clear(), dg.clear(), dp.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 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 dt.bulkAdd(d.transaction as never[]) if (d.goal && d.goal.length) await dg.bulkAdd(d.goal as never[]) if (d.plan && d.plan.length) await dp.bulkAdd(d.plan as never[]) }) // 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读 await commit() }