Files
daywork/intern-log/src/db/repo.ts
T

708 lines
24 KiB
TypeScript

// 数据仓库:全部业务数据只有服务器一份权威副本,本地 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<void> {
await flushAfterWrite()
}
// ---------- 设置 ----------
export async function getSetting(key: string, fallback: string): Promise<string> {
const row = await db.setting.get(key)
return row ? row.value : fallback
}
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 {
work_start: string
work_end: string
grace_minutes: number
lunch_minutes: number
remind_weekly: string
}
export async function getWorkRule(): Promise<WorkRule> {
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<string> {
return getSetting('last_location', '')
}
// 记录某天的上班地点;当天尚无打卡记录时先建一条仅含地点的记录
export async function setAttendanceLocation(date: string, location: string): Promise<void> {
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<string[]> {
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<void> {
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<void> {
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<Project[]> {
const all = await db.project.orderBy('name').toArray()
return includeArchived ? all : all.filter(p => p.archived !== 1)
}
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()
}
// ---------- 打卡 ----------
export async function getAttendance(date: string): Promise<Attendance | undefined> {
return db.attendance.where('date').equals(date).first()
}
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) {
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<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() })
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<void> {
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<void> {
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<Attendance[]> {
return db.attendance.where('date').between(start, end, true, true).toArray()
}
// 休假标记:标记时清空上下班时间,取消后恢复可打卡状态
export async function setLeave(date: string, leave: boolean): Promise<void> {
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<WorkLog> {
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<WorkLog | undefined> {
return db.work_log.where('date').equals(date).first()
}
export async function listLogItemsByDate(date: string): Promise<LogItem[]> {
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<void> {
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<LogItem>): Promise<void> {
await db.table('log_item').update(id, patch)
await commit()
}
export async function deleteLogItem(id: number): Promise<void> {
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.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<WeeklyNote | undefined> {
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<void> {
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<WeeklyNote[]> {
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<PlanItem[]> {
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<PlanItem | null> {
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<void> {
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<PlanItem>): Promise<void> {
await db.plan.update(id, { ...patch, updated_at: nowLocalISO() })
await commit()
}
export async function deletePlan(id: number): Promise<void> {
await db.plan.delete(id)
await commit()
}
// 打卡:完成 / 跳过 / 恢复
export async function setPlanStatus(id: number, status: 'todo' | 'done' | 'skip'): Promise<void> {
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<void> {
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<void> {
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<Goal[]> {
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<void> {
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<GoalInput>): Promise<void> {
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<void> {
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<Memo[]> {
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<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()
}
// ---------- 全局搜索 ----------
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<number, string>(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<Memo[]> {
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<Transaction[]> {
return db.table('transaction').where('date').between(start, end, true, true).toArray()
}
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()
}
// ---------- 备份 ----------
export async function exportAll(): Promise<string> {
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<void> {
let parsed: { schema_version?: number; data?: Record<string, unknown[]> } | 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()
}