初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
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()
|
||||
|
||||
const LEGACY_DB_NAME = 'intern_worklog'
|
||||
const SESSION_KEY = 'dsh_session_user'
|
||||
|
||||
export interface Session {
|
||||
userId: string
|
||||
username: string
|
||||
}
|
||||
|
||||
// 会话存 sessionStorage:关闭标签页即退出,刷新页面保持登录
|
||||
export function getSession(): Session | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY)
|
||||
if (!raw) return null
|
||||
const s = JSON.parse(raw)
|
||||
if (s && s.userId && s.username) return s as Session
|
||||
} catch { /* 忽略损坏会话 */ }
|
||||
return null
|
||||
}
|
||||
function setSession(s: Session): void {
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(s))
|
||||
}
|
||||
function clearSession(): void {
|
||||
sessionStorage.removeItem(SESSION_KEY)
|
||||
}
|
||||
|
||||
export function openUserDatabase(userId: string): void {
|
||||
bindDatabase(new WorklogDB(LEGACY_DB_NAME + '_' + 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() })
|
||||
}
|
||||
const session: Session = { userId: id, username: name }
|
||||
setSession(session)
|
||||
if (copied) showToastCopied(copied)
|
||||
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)
|
||||
return session
|
||||
}
|
||||
|
||||
export async function logoutUser(): Promise<void> {
|
||||
lockVault()
|
||||
unbindDatabase()
|
||||
clearSession()
|
||||
}
|
||||
|
||||
export async function changeLoginPassword(oldPwd: string, newPwd: string): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
|
||||
export async function deleteCurrentUser(): Promise<void> {
|
||||
const s = getSession()
|
||||
if (!s) throw new Error('未登录')
|
||||
lockVault()
|
||||
unbindDatabase()
|
||||
await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId)
|
||||
await registry.user.delete(s.userId)
|
||||
clearSession()
|
||||
}
|
||||
|
||||
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
|
||||
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 { /* 忽略 */ }
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type WeeklyNote, type WorkLog } from './schema'
|
||||
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
|
||||
|
||||
// ---------- 设置 ----------
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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)
|
||||
const row = await getAttendance(date)
|
||||
if (row && row.id) {
|
||||
await db.attendance.update(row.id, { location: loc || null })
|
||||
} 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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() })
|
||||
}
|
||||
|
||||
export async function renameProject(id: number, name: string): Promise<void> {
|
||||
await db.project.update(id, { name: name.trim() })
|
||||
}
|
||||
|
||||
export async function setProjectArchived(id: number, archived: boolean): Promise<void> {
|
||||
await db.project.update(id, { archived: archived ? 1 : 0 })
|
||||
}
|
||||
|
||||
// ---------- 打卡 ----------
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
if (loc) {
|
||||
await setSetting('last_location', loc)
|
||||
await rememberLocation(loc)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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() })
|
||||
}
|
||||
}
|
||||
|
||||
export async function smartClockOut(): Promise<string> {
|
||||
const now = new Date()
|
||||
const today = toDateStr(now)
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
throw new Error('没有进行中的打卡')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 直接修改某天的上/下班时间(行内编辑),状态由 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' })
|
||||
}
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工作日志 ----------
|
||||
|
||||
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> {
|
||||
const wl = await ensureWorkLog(date)
|
||||
await db.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()
|
||||
})
|
||||
await setSetting('last_project', data.project_id ? String(data.project_id) : '')
|
||||
await setSetting('last_category', data.category)
|
||||
}
|
||||
|
||||
export async function updateLogItem(id: number, patch: Partial<LogItem>): Promise<void> {
|
||||
await db.log_item.update(id, patch)
|
||||
}
|
||||
|
||||
export async function deleteLogItem(id: number): Promise<void> {
|
||||
await db.log_item.delete(id)
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWeeklyNotesBetween(start: string, end: string): Promise<WeeklyNote[]> {
|
||||
return db.weekly_note.where('week_start').between(start, end, true, true).toArray()
|
||||
}
|
||||
|
||||
// ---------- 备忘录 ----------
|
||||
|
||||
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 })
|
||||
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() })
|
||||
}
|
||||
|
||||
export async function deleteMemo(id: number): Promise<void> {
|
||||
await db.memo.delete(id)
|
||||
}
|
||||
|
||||
// ---------- 全局搜索 ----------
|
||||
|
||||
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.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 async function exportAll(): Promise<string> {
|
||||
const tables = await Promise.all([
|
||||
db.project.toArray(),
|
||||
db.work_log.toArray(),
|
||||
db.log_item.toArray(),
|
||||
db.attendance.toArray(),
|
||||
db.weekly_note.toArray(),
|
||||
db.setting.toArray(),
|
||||
db.memo.toArray(),
|
||||
db.credential.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]
|
||||
}
|
||||
}, 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.data) {
|
||||
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], 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 (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.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[])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
|
||||
export interface Project {
|
||||
id?: number
|
||||
name: string
|
||||
archived: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WorkLog {
|
||||
id?: number
|
||||
date: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface LogItem {
|
||||
id?: number
|
||||
work_log_id: number
|
||||
content: string
|
||||
project_id: number | null
|
||||
category: string
|
||||
minutes: number | null
|
||||
is_milestone: number
|
||||
needs_followup: number
|
||||
takeaway: string | null
|
||||
remark: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
id?: number
|
||||
date: string
|
||||
clock_in_at: string | null
|
||||
clock_out_at: string | null
|
||||
kind: string
|
||||
note: string | null
|
||||
location?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WeeklyNote {
|
||||
id?: number
|
||||
week_start: string
|
||||
highlights: string | null
|
||||
challenges: string | null
|
||||
plan_next: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Memo {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
id?: number
|
||||
system: string
|
||||
account: string
|
||||
password: string
|
||||
remark: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export class WorklogDB extends Dexie {
|
||||
project!: Table<Project, number>
|
||||
work_log!: Table<WorkLog, number>
|
||||
log_item!: Table<LogItem, number>
|
||||
attendance!: Table<Attendance, number>
|
||||
weekly_note!: Table<WeeklyNote, number>
|
||||
setting!: Table<{ key: string; value: string }, string>
|
||||
memo!: Table<Memo, number>
|
||||
credential!: Table<Credential, number>
|
||||
|
||||
constructor(dbName = 'intern_worklog') {
|
||||
super(dbName)
|
||||
this.version(1).stores({
|
||||
project: '++id, &name, archived',
|
||||
work_log: '++id, &date',
|
||||
log_item: '++id, work_log_id, project_id, category',
|
||||
attendance: '++id, &date',
|
||||
weekly_note: '++id, &week_start',
|
||||
setting: 'key'
|
||||
})
|
||||
this.version(2).stores({
|
||||
memo: '++id, updated_at',
|
||||
credential: '++id, system, updated_at'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 当前登录用户绑定的数据库实例:不同用户物理隔离不同库,登录后由 auth 模块注入
|
||||
export let db: WorklogDB | undefined
|
||||
|
||||
export function bindDatabase(instance: WorklogDB): void {
|
||||
db = instance
|
||||
}
|
||||
|
||||
export function unbindDatabase(): void {
|
||||
if (db) db.close()
|
||||
db = undefined
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 2
|
||||
|
||||
export const DEFAULT_LOCATIONS: string[] = ['公司', '客户现场', '出差', '居家办公']
|
||||
|
||||
export const CATEGORIES: { key: string; label: string }[] = [
|
||||
{ key: 'dev', label: '开发' },
|
||||
{ key: 'bugfix', label: 'BUG修复' },
|
||||
{ key: 'test', label: '测试' },
|
||||
{ key: 'learning', label: '学习成长' },
|
||||
{ key: 'meeting', label: '会议沟通' },
|
||||
{ key: 'doc', label: '文档输出' },
|
||||
{ key: 'other', label: '其他' }
|
||||
]
|
||||
|
||||
export function categoryLabel(key: string): string {
|
||||
const found = CATEGORIES.find(c => c.key === key)
|
||||
return found ? found.label : '其他'
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { db, type Credential } from './schema'
|
||||
import { getSetting, setSetting } from './repo'
|
||||
import { aesGcmDecrypt, aesGcmEncrypt, bufToB64, deriveKeyFromPassword, randomSaltB64 } from '../lib/crypto'
|
||||
import { nowLocalISO } from '../lib/date'
|
||||
|
||||
const VERIFY_TEXT = 'intern-log-vault-verify'
|
||||
const ENCRYPT_PREFIX = 'enc:v1:'
|
||||
|
||||
// 主密钥仅存于内存,页面刷新或手动锁定后需重新解锁
|
||||
let masterKey: CryptoKey | null = null
|
||||
|
||||
export function isVaultUnlocked(): boolean {
|
||||
return masterKey !== null
|
||||
}
|
||||
|
||||
export function lockVault(): void {
|
||||
masterKey = null
|
||||
}
|
||||
|
||||
export async function isVaultInitialized(): Promise<boolean> {
|
||||
return (await getSetting('pwd_salt', '')) !== ''
|
||||
}
|
||||
|
||||
export async function setupVault(password: string): Promise<void> {
|
||||
if (password.length < 6) throw new Error('主密码至少 6 位')
|
||||
if (await isVaultInitialized()) throw new Error('主密码已设置')
|
||||
const salt = randomSaltB64()
|
||||
const key = await deriveKeyFromPassword(password, salt)
|
||||
const verifier = await aesGcmEncrypt(key, VERIFY_TEXT)
|
||||
await setSetting('pwd_salt', salt)
|
||||
await setSetting('pwd_verifier', verifier)
|
||||
masterKey = key
|
||||
}
|
||||
|
||||
export async function unlockVault(password: string): Promise<void> {
|
||||
const salt = await getSetting('pwd_salt', '')
|
||||
if (!salt) throw new Error('主密码尚未设置')
|
||||
const key = await deriveKeyFromPassword(password, salt)
|
||||
const verifier = await getSetting('pwd_verifier', '')
|
||||
try {
|
||||
await aesGcmDecrypt(key, verifier)
|
||||
} catch {
|
||||
throw new Error('主密码不正确')
|
||||
}
|
||||
masterKey = key
|
||||
}
|
||||
|
||||
async function requireKey(): Promise<CryptoKey> {
|
||||
if (!masterKey) throw new Error('密码库未解锁')
|
||||
return masterKey
|
||||
}
|
||||
|
||||
export async function encryptSecret(plain: string): Promise<string> {
|
||||
const key = await requireKey()
|
||||
return ENCRYPT_PREFIX + await aesGcmEncrypt(key, plain)
|
||||
}
|
||||
|
||||
export async function decryptSecret(packed: string | null | undefined): Promise<string> {
|
||||
if (!packed) return ''
|
||||
if (!packed.startsWith(ENCRYPT_PREFIX)) return packed
|
||||
const key = await requireKey()
|
||||
return aesGcmDecrypt(key, packed.slice(ENCRYPT_PREFIX.length))
|
||||
}
|
||||
|
||||
// ---------- 账号密码 CRUD ----------
|
||||
|
||||
export async function listCredentials(): Promise<Credential[]> {
|
||||
return db.credential.orderBy('system').toArray()
|
||||
}
|
||||
|
||||
export interface CredentialInput {
|
||||
system: string
|
||||
account: string
|
||||
password: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export async function saveCredential(input: CredentialInput, id?: number): Promise<void> {
|
||||
const enc = await encryptSecret(input.password)
|
||||
const base = {
|
||||
system: input.system.trim(),
|
||||
account: input.account.trim(),
|
||||
password: enc,
|
||||
remark: input.remark.trim()
|
||||
}
|
||||
if (id) {
|
||||
await db.credential.update(id, { ...base, updated_at: nowLocalISO() })
|
||||
} else {
|
||||
await db.credential.add({ ...base, created_at: nowLocalISO(), updated_at: nowLocalISO() })
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCredential(id: number): Promise<void> {
|
||||
await db.credential.delete(id)
|
||||
}
|
||||
|
||||
export async function revealCredentialPassword(id: number): Promise<string> {
|
||||
const row = await db.credential.get(id)
|
||||
if (!row) throw new Error('记录不存在')
|
||||
return decryptSecret(row.password)
|
||||
}
|
||||
|
||||
// ---------- 修改主密码:旧密钥解密全部记录后用新密钥重新加密 ----------
|
||||
|
||||
export async function changeMasterPassword(oldPwd: string, newPwd: string): Promise<number> {
|
||||
if (!oldPwd) throw new Error('请输入旧主密码')
|
||||
if (newPwd.length < 6) throw new Error('新主密码至少 6 位')
|
||||
if (newPwd === oldPwd) throw new Error('新主密码不能与旧主密码相同')
|
||||
const salt = await getSetting('pwd_salt', '')
|
||||
if (!salt) throw new Error('密码库尚未初始化')
|
||||
const oldKey = await deriveKeyFromPassword(oldPwd, salt)
|
||||
const verifier = await getSetting('pwd_verifier', '')
|
||||
try {
|
||||
await aesGcmDecrypt(oldKey, verifier)
|
||||
} catch {
|
||||
throw new Error('旧主密码不正确')
|
||||
}
|
||||
const newKey = await deriveKeyFromPassword(newPwd, salt)
|
||||
const rows = await db.credential.toArray()
|
||||
let count = 0
|
||||
for (const row of rows) {
|
||||
if (row.id == null) continue
|
||||
const plain = await decryptSecretWithKey(row.password, oldKey)
|
||||
const enc = ENCRYPT_PREFIX + await aesGcmEncrypt(newKey, plain)
|
||||
await db.credential.update(row.id, { password: enc })
|
||||
count++
|
||||
}
|
||||
await setSetting('pwd_verifier', await aesGcmEncrypt(newKey, VERIFY_TEXT))
|
||||
masterKey = newKey
|
||||
return count
|
||||
}
|
||||
|
||||
async function decryptSecretWithKey(packed: string | null | undefined, key: CryptoKey): Promise<string> {
|
||||
if (!packed) return ''
|
||||
if (!packed.startsWith(ENCRYPT_PREFIX)) return packed
|
||||
return aesGcmDecrypt(key, packed.slice(ENCRYPT_PREFIX.length))
|
||||
}
|
||||
|
||||
// 供备份模块使用:生成新的随机盐(占位,保持导出接口一致)
|
||||
export function newSaltB64(): string {
|
||||
return bufToB64(crypto.getRandomValues(new Uint8Array(16)).buffer)
|
||||
}
|
||||
Reference in New Issue
Block a user