4 Commits
30 changed files with 1356 additions and 288 deletions
+25
View File
@@ -0,0 +1,25 @@
# v0.4.0 发布记录
发布日期:2026-10-16
## 新功能:计划模式
- 新增「计划」页:按天规划时间段(开始/结束时间、类别、备注),侧边栏"记录"组新增入口,手机端底部标签直达
- 条目级打卡:完成 / 跳过 / 恢复,日汇总(计划项数、完成数、计划时长、实际投入)
- 计时时钟:任意条目一键开始/停止计时,自动累计实际投入分钟;同刻仅一个计时,开始新计时自动停止上一个
- 计划数据进入同步体系,多端实时互通
## 新功能:范围化导出
- 设置 → 工作详情导出:当日 / 当周 / 当月 / 自定义时间段一键导出 CSV(含分类、项目、耗时、成果、待跟进、收获、备注)
## 新功能:分类自定义
- 工作记录分类改为用户自定义(默认:制度学习 / 产品学习 / 打杂 / 项目实操 / 售后服务),设置页可添加、改名、删除,随账号同步
- 记账支出 / 收入分类同样自定义,记账弹窗内可直接"新增分类"
- 历史数据自动迁移:旧固定分类(开发/BUG修复/学习成长…)按语义映射到新分类,报表与统计自动跟随
## 升级说明
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build(需包含本次 plan 表的服务端支持)
- 客户端覆盖安装即可,本地库自动升级到 v5 并完成分类迁移
+25
View File
@@ -0,0 +1,25 @@
# v0.5.0 发布记录
发布日期:2026-10-16
## 计划模式增强:长期计划
- 长期计划(目标)跨越多天:设置起止日期后自动**物化为每一天的任务**,落实到每天打卡与计时
- 目标进度条:已完成天数 / 总天数一目了然;编辑目标自动级联更新未完成的每日任务
- 每日任务带「长期」徽标,可与当日临时计划混排;删除目标时二次确认
- 数据模型新增 goal 表(v6),goal ↔ plan 外键随多端同步(引擎级冒烟验证外键水合正确)
## 界面原则:内容优先
- 记账页:移除内联新增表单,改为「记一笔」按钮弹窗填写,月度汇总与账目表前置
- 当日详情:移除内联快速记录表单,改为「添加记录」按钮弹窗填写,记录列表前置
- 工作详情导出从设置页移至详情页,并支持 **Markdown 格式**(当日 / 当周 / 当月 / 自定义时间段,含考勤与逐条记录)
## 上一版(v0.4.0)要点回顾
计划模式(时间段+打卡+计时时钟)、工作与记账分类自定义、工作详情范围导出 CSV
## 升级说明
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build
- 客户端覆盖安装;本地库自动升级至 v6
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-C-z1ujqe.js","./index-DT_fURyb.js","./index-2EGKD3r-.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DT_fURyb.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-C-z1ujqe.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-e9jKyg3_.js","./index-DLDMtYxl.js","./index-ieoBMDLY.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DLDMtYxl.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-e9jKyg3_.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DT_fURyb.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-2EGKD3r-.css">
<script type="module" crossorigin src="./assets/index-DLDMtYxl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-ieoBMDLY.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.3.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+38
View File
@@ -0,0 +1,38 @@
import 'fake-indexeddb/auto'
const BASE = 'http://127.0.0.1:3780'
const store = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => { store.set(k, String(v)) },
removeItem: (k: string) => { store.delete(k) }
}
store.set('dsh_cloud_creds', JSON.stringify({ username: 'goal_smoke', password: 'goal123' }))
store.set('dsh_sync_base', BASE)
async function main() {
const reg = await fetch(BASE + '/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'goal_smoke', password: 'goal123' })
}).catch(() => null)
const schema = await import('../src/db/schema')
const engine = await import('../src/sync/engine')
schema.bindDatabase(new schema.WorklogDB('gs_a'))
const a = schema.db
// 设备 A:建 goal + plan(goal_id 指向本地 goal)
const gid = await a.goal.add({ title: '产品文档学习', kind: '学习', start_date: '2026-10-16', end_date: '2026-10-18', note: null, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
await a.plan.add({ date: '2026-10-16', start_time: '08:00', end_time: '09:00', title: '产品文档学习', kind: '学习', note: null, status: 'todo', actual_minutes: 0, timer_started_at: null, goal_id: gid, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
const rA = await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
console.log('A push:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// 设备 B:空库拉取
schema.bindDatabase(new schema.WorklogDB('gs_b'))
await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
const b = schema.db
const goals = await b.goal.toArray()
const plans = await b.plan.toArray()
const okGoal = goals.length === 1
const okFk = plans.length === 1 && plans[0].goal_id === goals[0].id && plans[0].title === '产品文档学习'
console.log((okGoal ? 'PASS' : 'FAIL') + ' B 拉到长期计划')
console.log((okFk ? 'PASS' : 'FAIL') + ' plan.goal_id 正确水合到本地 goal(' + JSON.stringify({ goalId: plans[0] && plans[0].goal_id, localGoalId: goals[0] && goals[0].id }) + ')')
process.exit(okGoal && okFk ? 0 : 1)
}
main().catch(e => { console.error('FAILED:', (e && (e.stack || e.message)) || e); process.exit(1) })
+8 -1
View File
@@ -26,7 +26,8 @@ async function main() {
check('错误密码登录 401', bad.status === 401, bad.status)
const ok = await post('/api/auth/login', { username: 'audit_a', password: 'aapass123' })
check('正确密码登录 200', ok.status === 200 && !!ok.body.token, ok.status)
const tokA = reg.body.token, tokA2 = ok.body.token
let tokA = reg.body.token
const tokA2 = ok.body.token
const noAuth = await post('/api/sync', { tables: {} })
check('无 token 同步被拒 401', noAuth.status === 401, noAuth.status)
const regB = await post('/api/auth/register', { username: 'audit_b', password: 'bbpass123' })
@@ -85,6 +86,10 @@ async function main() {
check('旧密码立即失效 401', oldLogin.status === 401, oldLogin.status)
const newLogin = await post('/api/auth/login', { username: 'audit_a', password: 'aapass456' })
check('新密码登录 200', newLogin.status === 200, newLogin.status)
// 改密后旧令牌立即失效(会话吊销)
const oldTokSync = await post('/api/sync', { tables: {} }, tokA)
check('改密后旧 token 同步被拒 401', oldTokSync.status === 401, oldTokSync.status)
tokA = newLogin.body.token
const chgWrong = await post('/api/auth/password', { oldPassword: 'zzz', newPassword: 'whatever1' }, tokA)
check('错误旧密码改密被拒 400', chgWrong.status === 400, chgWrong.status)
@@ -123,6 +128,8 @@ async function main() {
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
await new Promise(r => globalThis.setTimeout(r, 400))
check('启动自动备份已生成', fsMod.existsSync('server/data/backups/' + new Date().toISOString().slice(0, 10) + '/registry.json'))
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
const d1 = await post('/api/auth/delete', {}, tokA)
check('删除账号 200', d1.status === 200, d1.body)
+33 -17
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import Icon from './components/Icon.vue'
import CalendarView from './views/CalendarView.vue'
import TodayView from './views/TodayView.vue'
import PlanView from './views/PlanView.vue'
import StatsView from './views/StatsView.vue'
import ReportView from './views/ReportView.vue'
import LedgerView from './views/LedgerView.vue'
@@ -110,15 +111,16 @@ function onGlobalKey(e: KeyboardEvent) {
}
const VIEWS = [
{ key: 'calendar', name: '日历', icon: 'calendar' },
{ key: 'today', name: '详情', icon: 'edit' },
{ key: 'stats', name: '统计', icon: 'chart' },
{ key: 'report', name: '总结', icon: 'export' },
{ key: 'ledger', name: '记账', icon: 'wallet' },
{ key: 'memo', name: '备忘录', icon: 'memo' },
{ key: 'vault', name: '密码库', icon: 'lock' },
{ key: 'settings', name: '设置', icon: 'settings' },
{ key: 'admin', name: '用户管理', icon: 'shield' }
{ key: 'calendar', name: '日历', icon: 'calendar', group: '记录' },
{ key: 'today', name: '详情', icon: 'edit', group: '记录' },
{ key: 'plan', name: '计划', icon: 'plan', group: '记录' },
{ key: 'ledger', name: '记账', icon: 'wallet', group: '工具' },
{ key: 'memo', name: '备忘录', icon: 'memo', group: '工具' },
{ key: 'stats', name: '统计', icon: 'chart', group: '分析' },
{ key: 'report', name: '总结', icon: 'export', group: '分析' },
{ key: 'vault', name: '密码库', icon: 'lock', group: '系统' },
{ key: 'settings', name: '设置', icon: 'settings', group: '系统' },
{ key: 'admin', name: '用户管理', icon: 'shield', group: '系统' }
]
function readRoute(): string {
@@ -145,7 +147,7 @@ watch([isAdminUser, route], () => {
}, { immediate: true })
// 手机端底部标签页:4 个主功能 + 「更多」弹层(统计/总结/密码库/设置/退出)
const TAB_KEYS = ['calendar', 'today', 'ledger', 'memo']
const TAB_KEYS = ['calendar', 'today', 'plan', 'ledger']
const TABS = VIEWS.filter(v => TAB_KEYS.includes(v.key))
const moreOpen = ref(false)
function navFromSheet(key: string) { moreOpen.value = false; nav(key) }
@@ -233,13 +235,15 @@ onMounted(async () => {
<LoginView v-if="!session" @login="onLogin" />
<div v-else class="app">
<nav class="side">
<button
v-for="v in shownViews" :key="v.key"
class="nav-btn" :class="{ active: route === v.key }"
:data-tip="v.name" @click="nav(v.key)"
>
<Icon :name="v.icon" :size="18" />
</button>
<template v-for="(v, i) in shownViews" :key="v.key">
<div v-if="i > 0 && v.group !== shownViews[i - 1].group" class="nav-sep"></div>
<button
class="nav-btn" :class="{ active: route === v.key }"
:data-tip="v.name" @click="nav(v.key)"
>
<Icon :name="v.icon" :size="18" />
</button>
</template>
</nav>
<div class="main">
<header class="topbar">
@@ -289,6 +293,7 @@ onMounted(async () => {
<div class="content-inner">
<CalendarView v-if="route === 'calendar'" />
<TodayView v-else-if="route === 'today'" />
<PlanView v-else-if="route === 'plan'" />
<StatsView v-else-if="route === 'stats'" />
<ReportView v-else-if="route === 'report'" />
<LedgerView v-else-if="route === 'ledger'" />
@@ -420,6 +425,17 @@ onMounted(async () => {
transition: opacity 0.15s;
}
.nav-btn:hover::after { opacity: 1; }
.nav-sep {
width: 26px;
height: 1px;
flex: none;
background: var(--border);
margin: 5px 0;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
import { getLedgerCategories, setLedgerCategories } from '../db/categories'
const props = defineProps<{
open: boolean
@@ -15,7 +15,7 @@ const emit = defineEmits<{
}>()
const fType = ref<'expense' | 'income'>('expense')
const fCategory = ref('餐饮')
const fCategory = ref('')
const fAmount = ref('')
const fDate = ref('')
const fNote = ref('')
@@ -30,6 +30,7 @@ function onKey(e: KeyboardEvent) {
}
watch(() => props.open, async v => {
if (v) {
await loadCats()
fType.value = (props.initial.type === 'income' ? 'income' : 'expense')
fCategory.value = props.initial.category
fAmount.value = props.initial.amountYuan
@@ -51,7 +52,20 @@ watch(fType, t => {
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
})
const categoryOptions = computed(() => (fType.value === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES))
const categoryOptions = ref<string[]>([])
async function loadCats() {
categoryOptions.value = await getLedgerCategories(fType.value)
}
const newCat = ref('')
async function addCategory() {
const name = newCat.value.trim()
if (!name) return
if (categoryOptions.value.includes(name)) { newCat.value = ''; return }
categoryOptions.value.push(name)
await setLedgerCategories(fType.value, categoryOptions.value)
fCategory.value = name
newCat.value = ''
}
function computedCategory() {
return (fCategory.value && categoryOptions.value.includes(fCategory.value)) ? fCategory.value : categoryOptions.value[0]
}
@@ -109,10 +123,14 @@ function save() {
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类</label>
<label>分类(可直接新增)</label>
<select class="select" v-model="fCategory">
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
<div class="add-cat">
<input class="input add-cat-input" v-model="newCat" placeholder="新增分类名" @keydown.enter.prevent="addCategory" />
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
</div>
</div>
<div class="field" v-if="fType === 'expense'">
<label>报销状态</label>
@@ -164,6 +182,8 @@ function save() {
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
+2 -1
View File
@@ -24,5 +24,6 @@ export const ICONS: Record<string, string> = {
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>')
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
plan: svg('<rect x="4.5" y="4" width="15" height="16.5" rx="2"/><path d="M8.5 9.5l1.4 1.4 2.6-2.9M8.5 15l1.4 1.4 2.6-2.9M15 9.8h.01M15 15.3h.01"/>')
}
+24 -109
View File
@@ -8,13 +8,16 @@ import {
getLocationPresets, getWorkRule, listLogItemsByDate, listProjects, saveMakeup,
setAttendanceLocation, setAttendanceTime, setLeave, updateLogItem
} from '../../db/repo'
import { CATEGORIES, categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { getLogCategories } from '../../db/categories'
import { formatDateCN, hhmm, todayStr } from '../../lib/date'
import { computeDay, type DayAttendanceInfo } from '../../domain/attendance'
import type { WorkRule } from '../../db/repo'
import { showToast } from '../../lib/toast'
import { attendanceChanged, notifyAttendanceChanged } from '../../lib/bus'
const logCats = ref<string[]>([])
getLogCategories().then(l => { logCats.value = l })
const props = defineProps<{ date: string }>()
const loading = ref(true)
@@ -158,61 +161,15 @@ async function saveMakeupForm() {
showToast('补卡已保存')
}
// ---- 新增工作记录 ----
const content = ref('')
const contentError = ref('')
watch(content, () => {
if (contentError.value) contentError.value = ''
})
const projectId = ref<number | null>(null)
const category = ref('dev')
const minutesVal = ref<number | null>(null)
const takeawayVal = ref('')
const remarkVal = ref('')
const submitting = ref(false)
const formDirty = computed(() => !!(content.value.trim() || minutesVal.value || takeawayVal.value.trim() || remarkVal.value.trim()))
onMounted(async () => {
const lp = await getLastPick()
projectId.value = lp.project_id
category.value = lp.category
})
function resetForm() {
content.value = ''
minutesVal.value = null
takeawayVal.value = ''
remarkVal.value = ''
contentError.value = ''
}
async function submit() {
const text = content.value.trim()
if (!text) {
contentError.value = '请填写工作内容'
return
}
if (submitting.value) return
submitting.value = true
await addLogItem(props.date, {
content: text,
project_id: projectId.value,
category: category.value,
minutes: minutesVal.value && minutesVal.value > 0 ? minutesVal.value : null,
takeaway: takeawayVal.value.trim() || null,
remark: remarkVal.value.trim() || null
})
resetForm()
await load()
showToast('记录已添加')
submitting.value = false
}
// ---- 条目操作 ----
const editTarget = ref<LogItem | null>(null)
function openEdit(it: LogItem) {
editTarget.value = it
}
function openAdd() {
editTarget.value = null
}
async function onEditSave(payload: {
id: number
content: string
@@ -224,21 +181,22 @@ async function onEditSave(payload: {
is_milestone: number
needs_followup: number
}) {
const id = payload.id
const patch = {
content: payload.content,
project_id: payload.project_id,
category: payload.category,
minutes: payload.minutes,
takeaway: payload.takeaway,
remark: payload.remark,
is_milestone: payload.is_milestone,
needs_followup: payload.needs_followup
if (payload.id) {
await updateLogItem(payload.id, {
content: payload.content, project_id: payload.project_id, category: payload.category,
minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark,
is_milestone: payload.is_milestone, needs_followup: payload.needs_followup
})
showToast('记录已更新')
} else {
await addLogItem(props.date, {
content: payload.content, project_id: payload.project_id, category: payload.category,
minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark
})
showToast('记录已添加')
}
await updateLogItem(id, patch)
editTarget.value = null
await load()
showToast('修改已保存')
}
async function toggleMilestone(it: LogItem) {
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
@@ -378,54 +336,11 @@ async function confirmDelete() {
</div>
</div>
<div class="card sec">
<div class="sec-head">
<span class="sec-title">新增工作记录</span>
<span class="muted form-hint">内容必填,其余选填</span>
</div>
<div class="form-grid">
<div class="field span2">
<label>工作内容(必填)</label>
<input
class="input" :class="{ invalid: contentError }"
v-model="content" placeholder="做了什么,一句话一条" @keydown.enter="submit"
/>
<div v-if="contentError" class="form-error">{{ contentError }}</div>
</div>
<div class="field">
<label>所属项目</label>
<select class="select" v-model="projectId">
<option :value="null">无项目</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="field">
<label>工作分类</label>
<select class="select" v-model="category">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="minutesVal" placeholder="选填" />
</div>
<div class="field">
<label>主要收获</label>
<input class="input" v-model="takeawayVal" placeholder="一句话:学到了什么" />
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="remarkVal" placeholder="背景、链接、未决事项(选填)"></textarea>
</div>
</div>
<div class="form-footer">
<button class="btn btn-sm" :disabled="!formDirty" @click="resetForm">重置</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="submit">添加记录</button>
</div>
</div>
<div class="list-sec">
<div class="sec-title">工作记录({{ items.length }})</div>
<div class="sec-head">
<span class="sec-title">工作记录({{ items.length }})</span>
<button class="btn btn-primary btn-sm" @click="openAdd"><Icon name="plus" :size="14" /> 添加记录</button>
</div>
<div v-if="!items.length && !loading" class="empty muted">暂无记录</div>
<div v-for="it in items" :key="it.id" class="item">
<div class="item-main">
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import { CATEGORIES, type LogItem, type Project } from '../../db/schema'
import { type LogItem, type Project } from '../../db/schema'
import { getLogCategories } from '../../db/categories'
const logCats = ref<string[]>([])
getLogCategories().then(l => { logCats.value = l })
const props = defineProps<{
open: boolean
item: LogItem | null
@@ -86,7 +89,7 @@ function save() {
return
}
if (!props.item || !props.item.id) {
emit('cancel')
emit('save', { id: 0, content: text, project_id: formProjectId.value, category: formCategory.value, minutes: formMinutes.value && formMinutes.value > 0 ? formMinutes.value : null, takeaway: formTakeaway.value.trim() || null, remark: formRemark.value.trim() || null, is_milestone: formMilestone.value, needs_followup: formFollowup.value })
return
}
emit('save', {
@@ -137,7 +140,7 @@ function save() {
<div class="field">
<label>工作分类</label>
<select class="select" v-model="formCategory">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field">
+39
View File
@@ -0,0 +1,39 @@
// 分类管理:工作分类与记账分类均为用户数据(存于服务器随账号同步)
// 未自定义时返回默认值;分类值即显示文本(历史遗留 key 由 categoryLabel 直通转换)
import { getSetting, setSetting } from './repo'
export const DEFAULT_LOG_CATEGORIES: string[] = ['制度学习', '产品学习', '打杂', '项目实操', '售后服务']
export const DEFAULT_EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const DEFAULT_INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
const LOG_KEY = 'log_categories'
const EXPENSE_KEY = 'ledger_expense_categories'
const INCOME_KEY = 'ledger_income_categories'
function parse(raw: string, fallback: string[]): string[] {
if (!raw) return fallback.slice()
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) {
const clean = arr.filter((x: unknown) => typeof x === 'string' && x.trim()).map((x: string) => x.trim())
if (clean.length) return Array.from(new Set(clean))
}
} catch { /* 损坏数据回退默认 */ }
return fallback.slice()
}
export async function getLogCategories(): Promise<string[]> {
return parse(await getSetting(LOG_KEY, ''), DEFAULT_LOG_CATEGORIES)
}
export async function setLogCategories(list: string[]): Promise<void> {
await setSetting(LOG_KEY, JSON.stringify(list))
}
export async function getLedgerCategories(type: 'expense' | 'income'): Promise<string[]> {
return parse(await getSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, ''), type === 'expense' ? DEFAULT_EXPENSE_CATEGORIES : DEFAULT_INCOME_CATEGORIES)
}
export async function setLedgerCategories(type: 'expense' | 'income', list: string[]): Promise<void> {
await setSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, JSON.stringify(list))
}
+194 -6
View File
@@ -1,7 +1,7 @@
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 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 { 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'
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
import { flushAfterWrite } from '../sync/engine'
@@ -360,6 +360,186 @@ export async function listWeeklyNotesBetween(start: string, end: string): Promis
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 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[]> {
@@ -457,7 +637,9 @@ export async function exportAll(): Promise<string> {
db.setting.toArray(),
db.memo.toArray(),
db.credential.toArray(),
db.table('transaction').toArray()
db.table('transaction').toArray(),
db.plan.toArray(),
db.goal.toArray()
])
return JSON.stringify({
schema_version: SCHEMA_VERSION,
@@ -471,7 +653,9 @@ export async function exportAll(): Promise<string> {
setting: tables[5],
memo: tables[6],
credential: tables[7],
transaction: tables[8]
transaction: tables[8],
plan: tables[9],
goal: tables[10]
}
}, null, 2)
}
@@ -483,17 +667,19 @@ export async function importAll(jsonText: string): Promise<void> {
} catch {
throw new Error('不是有效的 JSON 文件')
}
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3) || !parsed.data) {
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')
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt], async () => {
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()
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[])
@@ -504,6 +690,8 @@ export async function importAll(jsonText: string): Promise<void> {
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()
+65 -12
View File
@@ -48,6 +48,35 @@ export interface WeeklyNote {
created_at: string
}
export interface Goal {
id?: number
title: string // 长期计划,如:学完产品文档 / 本月健身 20 次
kind: string
start_date: string // YYYY-MM-DD
end_date: string
note: string | null
uuid: string
created_at: string
updated_at: string
}
export interface PlanItem {
id?: number
date: string // YYYY-MM-DD
start_time: string // HH:mm
end_time: string // HH:mm
title: string // 计划内容,如:产品学习 / 健身
kind: string // 类别:学习 / 健身 / 自律 / 其他自定义
note: string | null
status: string // todo | done | skip
actual_minutes: number // 计时时钟累计的实际投入分钟
timer_started_at: string | null
goal_id: number | null // 来源长期计划(本地 id)
uuid: string
created_at: string
updated_at: string
}
export interface Memo {
id?: number
title: string
@@ -107,6 +136,8 @@ export class WorklogDB extends Dexie {
// 因为 db.transaction 会与 Dexie 内置的 transaction() 方法冲突
sync_mirror!: Table<SyncMirror, number>
tombstone!: Table<SyncTombstone, number>
plan!: Table<PlanItem, number>
goal!: Table<Goal, number>
constructor(dbName = 'intern_worklog') {
super(dbName)
@@ -129,6 +160,27 @@ export class WorklogDB extends Dexie {
sync_mirror: '++id, table, uuid',
tombstone: '++id, table, uuid, deleted_at'
})
this.version(5).stores({
plan: '++id, date, status'
}).upgrade(async tx => {
// 旧固定分类 key 迁移为用户自定义分类标签(v0.4.0 起分类为用户数据)
const map: Record<string, string> = {
dev: '项目实操',
test: '项目实操',
bugfix: '售后服务',
learning: '产品学习',
meeting: '打杂',
doc: '打杂',
other: '打杂'
}
await tx.table('log_item').toCollection().modify((item: { category?: string }) => {
if (item.category && map[item.category]) item.category = map[item.category]
})
})
this.version(6).stores({
plan: '++id, date, status, goal_id',
goal: '++id, start_date'
})
}
}
@@ -144,24 +196,25 @@ export function unbindDatabase(): void {
db = undefined
}
export const SCHEMA_VERSION = 4
export const SCHEMA_VERSION = 6
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
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: '其他' }
]
// 旧版固定分类 key → 默认标签的映射(仅用于历史数据迁移与展示兜底)
export const LEGACY_CATEGORY_LABELS: Record<string, string> = {
dev: '项目实操',
test: '项目实操',
bugfix: '售后服务',
learning: '产品学习',
meeting: '打杂',
doc: '打杂',
other: '打杂'
}
// v0.4.0 起分类即用户自定义标签本身;历史 key 在此直通转换
export function categoryLabel(key: string): string {
const found = CATEGORIES.find(c => c.key === key)
return found ? found.label : '其他'
return LEGACY_CATEGORY_LABELS[key] || key
}
+13 -6
View File
@@ -1,5 +1,6 @@
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects, listWeeklyNotesBetween } from '../db/repo'
import { CATEGORIES, type LogItem } from '../db/schema'
import { type LogItem } from '../db/schema'
import { getLogCategories } from '../db/categories'
import { todayStr } from '../lib/date'
import { computeDay } from './attendance'
@@ -44,13 +45,15 @@ export async function buildReport(start: string, end: string, label: string): Pr
listAttendanceBetween(start, end),
listLogItemsBetween(start, end),
listProjects(true),
listWeeklyNotesBetween(start, end)
listWeeklyNotesBetween(start, end),
getLogCategories()
])
const rule = results[0]
const atts = results[1]
const items = results[2]
const projects = results[3]
const notes = results[4]
const logCats: string[] = results[5] as string[]
let attendDays = 0
let workedMin = 0
@@ -73,10 +76,14 @@ export async function buildReport(start: string, end: string, label: string): Pr
.sort((a, b) => b.count - a.count)
const total = items.length || 1
const categoryStats: CategoryStat[] = CATEGORIES
.map(c => {
const count = items.filter((i: LogItem) => i.category === c.key).length
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
const catCount2 = new Map<string, number>()
for (const it of items) catCount2.set(it.category, (catCount2.get(it.category) || 0) + 1)
const catSet2 = new Set(logCats)
for (const k of catCount2.keys()) if (!catSet2.has(k)) logCats.push(k)
const categoryStats: CategoryStat[] = logCats
.map(label => {
const count = catCount2.get(label) || 0
return { key: label, label, count, percent: Math.round((count / total) * 100) }
})
.filter(c => c.count > 0)
.sort((a, b) => b.count - a.count)
+6
View File
@@ -15,6 +15,12 @@ export function showFatalError(title: string, detail: string): void {
const box = document.createElement('div')
box.style.cssText = 'border:1px solid #E5484D;border-radius:8px;padding:12px;margin-bottom:12px;word-break:break-all;white-space:pre-wrap;'
box.textContent = title + '\n' + detail
// 重试入口:纯在线模式下连不上服务器时(如启动失败),一键重载而不是干等着重启应用
const retry = document.createElement('button')
retry.textContent = '重新加载'
retry.style.cssText = 'margin:-4px 0 12px;padding:6px 16px;border:1px solid #E5484D;border-radius:6px;background:#FFFFFF;color:#E5484D;font-size:13px;cursor:pointer;'
retry.addEventListener('click', () => { try { location.reload() } catch { /* 忽略 */ } })
el.appendChild(box)
el.appendChild(retry)
} catch { /* 浮层失败时保持静默 */ }
}
+21 -2
View File
@@ -13,7 +13,7 @@ import { DEFAULT_SYNC_BASE, getSyncBase as baseOf, getToken, setToken, setSyncBa
// 最近一次自动同步的错误(设置页展示用)
export const syncLastError = ref('')
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
// 本地临时性键不参与同步,也不能从服务端写回本地缓存
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
@@ -175,6 +175,7 @@ export async function flushAfterWrite(): Promise<void> {
interface RefMaps {
workLog: Map<number, AnyRow>
project: Map<number, AnyRow>
goal: Map<number, AnyRow>
}
function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
@@ -191,15 +192,22 @@ function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
const pj = row.project_id ? refs.project.get(row.project_id) : null
if (pj && pj.uuid) clean.puuid = pj.uuid
}
if (t === 'plan') {
delete clean.goal_id
const g = row.goal_id ? refs.goal.get(row.goal_id) : null
if (g && g.uuid) clean.guuid = g.uuid
}
return clean
}
async function buildRefMaps(): Promise<RefMaps> {
const wlRows = await db.work_log.toArray()
const pjRows = await db.project.toArray()
const gRows = await db.goal.toArray()
return {
workLog: new Map(wlRows.map(r => [r.id as number, r])),
project: new Map(pjRows.map(p => [p.id as number, p]))
project: new Map(pjRows.map(p => [p.id as number, p])),
goal: new Map(gRows.map(r => [r.id as number, r]))
}
}
@@ -398,6 +406,7 @@ async function applySnapshot(
let removed = 0
const wlIds: { [uuid: string]: number } = {}
const pjIds: { [uuid: string]: number } = {}
const goalIds: { [uuid: string]: number } = {}
for (const t of TABLES) {
const table = db.table(t)
@@ -431,6 +440,15 @@ async function applySnapshot(
srow.project_id = srow.puuid ? (pjIds[srow.puuid] ?? null) : null
delete srow.puuid
}
if (t === 'plan') {
if (srow.guuid) {
const gid = goalIds[srow.guuid]
srow.goal_id = gid ?? null
delete srow.guuid
} else {
srow.goal_id = null
}
}
let id: any = pkOf(t, srow0)
if (t === 'setting') id = srow0.key
else {
@@ -445,6 +463,7 @@ async function applySnapshot(
hydrated.push(rowOut)
if (t === 'work_log') wlIds[srow0.uuid] = id
if (t === 'project') pjIds[srow0.uuid] = id
if (t === 'goal') goalIds[srow0.uuid] = id
}
void importance
const outRows = hydrated.concat(inflight)
+29 -56
View File
@@ -7,7 +7,7 @@ import {
addTransaction, deleteTransaction, listTransactionsBetween,
setTransactionReimbursed, updateTransaction, type Transaction
} from '../db/repo'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
import { getLedgerCategories } from '../db/categories'
import { addMonths, todayStr } from '../lib/date'
import { showToast } from '../lib/toast'
@@ -20,7 +20,7 @@ const loading = ref(true)
// ---- 新增表单 ----
const fType = ref<TxType>('expense')
const fAmount = ref('')
const fCategory = ref(EXPENSE_CATEGORIES[0])
const fCategory = ref('')
const fDate = ref(todayStr())
const fNote = ref('')
const fReimb = ref(false)
@@ -30,7 +30,7 @@ const submitting = ref(false)
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const dialogInitial = ref({ type: 'expense', category: EXPENSE_CATEGORIES[0], amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
const deleteTarget = ref<Transaction | null>(null)
const monthStart = computed(() => monthRef.value.slice(0, 7) + '-01')
@@ -56,9 +56,14 @@ async function load() {
onMounted(load)
watch(monthRef, load)
watch(fType, t => {
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
})
const expenseCats = ref<string[]>([])
const incomeCats = ref<string[]>([])
async function loadCats() {
expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income')
}
loadCats()
const sorted = computed(() =>
rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1)))
@@ -183,12 +188,24 @@ function openEdit(row: Transaction) {
}
dialogOpen.value = true
}
function openCreate() {
editingId.value = null
dialogInitial.value = { type: 'expense', category: (expenseCats.value[0] || ''), amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 }
dialogOpen.value = true
}
async function onDialogSave(payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }) {
const id = editingId.value as number
await updateTransaction(id, payload)
if (editingId.value) {
await updateTransaction(editingId.value, payload)
dialogOpen.value = false
await load()
showToast('账目已更新')
return
}
await addTransaction({ type: payload.type, category: payload.category, amount: payload.amount, date: payload.date, note: payload.note, reimbursable: payload.reimbursable, reimbursed: payload.reimbursed })
dialogOpen.value = false
await load()
showToast('账目已更新')
showToast('已记一笔')
}
async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) await deleteTransaction(deleteTarget.value.id)
@@ -216,6 +233,7 @@ async function confirmDelete() {
<option value="pending">待报销</option>
<option value="done">已报销</option>
</select>
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 记一笔</button>
</div>
<div class="cards">
@@ -225,51 +243,6 @@ async function confirmDelete() {
<div class="card stat-card"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
</div>
<div class="card sec">
<div class="sec-head">
<span class="sec-title">新增一笔</span>
<span class="muted form-hint">金额必填,回车快速添加</span>
</div>
<div class="form-grid">
<div class="field span2">
<label>类型</label>
<div class="seg">
<button class="seg-btn" :class="{ active: fType === 'expense' }" @click="fType = 'expense'">支出</button>
<button class="seg-btn" :class="{ active: fType === 'income' }" @click="fType = 'income'">收入</button>
</div>
</div>
<div class="field">
<label>金额(元,必填)</label>
<input class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" @keydown.enter="submit" />
</div>
<div class="field">
<label>日期</label>
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类</label>
<select class="select" v-model="fCategory">
<option v-for="c in (fType === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES)" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field" v-if="fType === 'expense'">
<label>报销</label>
<button class="toggle-btn" :class="{ on: fReimb }" @click="fReimb = !fReimb">
{{ fReimb ? '需要报销' : '无需报销' }}
</button>
</div>
<div class="field span2">
<label>备注</label>
<input class="input" v-model="fNote" placeholder="选填,如:客户现场午餐" @keydown.enter="submit" />
</div>
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
<div class="form-footer">
<button class="btn btn-sm" :disabled="!formDirty" @click="resetForm">重置</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="submit">添加记录</button>
</div>
</div>
<div class="card sec" v-if="categoryStats.length">
<div class="sec-title">本月支出分类</div>
<div v-for="c in categoryStats" :key="c.name" class="bar-row">
@@ -320,11 +293,11 @@ async function confirmDelete() {
</table>
</div>
<div class="muted vault-note num">筛选结果 {{ filtered.length }} 条 / 本月共 {{ rows.length }} 条</div>
<div class="muted vault-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
<TransactionDialog
:open="dialogOpen"
:title="editingId ? '编辑账目' : '编辑账目'"
:title="editingId ? '编辑账目' : '记一笔'"
:initial="dialogInitial"
@cancel="dialogOpen = false"
@save="onDialogSave"
+448
View File
@@ -0,0 +1,448 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import {
addPlan, deletePlan, listPlansByDate, setPlanStatus,
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem,
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
} from '../db/repo'
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
import { showToast } from '../lib/toast'
// ---- 长期计划 ----
const goals = ref<Goal[]>([])
const goalProg = ref<Record<string, { done: number; total: number }>>({})
const goalOpen = ref(false)
const goalEditing = ref<Goal | null>(null)
const gTitle = ref('')
const gKind = ref('学习')
const gStart = ref(todayStr())
const gEnd = ref(addDays(todayStr(), 6))
const gNote = ref('')
const gError = ref('')
const delGoal = ref<Goal | null>(null)
function openGoalCreate() {
goalEditing.value = null
gTitle.value = ''
gKind.value = '学习'
gStart.value = todayStr()
gEnd.value = addDays(todayStr(), 6)
gNote.value = ''
gError.value = ''
goalOpen.value = true
}
function openGoalEdit(g: Goal) {
goalEditing.value = g
gTitle.value = g.title
gKind.value = g.kind
gStart.value = g.start_date
gEnd.value = g.end_date
gNote.value = g.note || ''
gError.value = ''
goalOpen.value = true
}
async function submitGoal() {
if (!gTitle.value.trim()) { gError.value = '请填写计划名称'; return }
if (gEnd.value < gStart.value) { gError.value = '结束日期不能早于开始日期'; return }
if (goalEditing.value) {
await updateGoal(goalEditing.value.id as number, {
title: gTitle.value.trim(), kind: gKind.value, note: gNote.value
})
showToast('长期计划已更新')
} else {
await addGoal({ title: gTitle.value, kind: gKind.value, start_date: gStart.value, end_date: gEnd.value, note: gNote.value })
showToast('长期计划已创建,已生成每日任务')
}
goalOpen.value = false
await load()
}
async function removeGoal() {
if (!delGoal.value) return
await deleteGoal(delGoal.value.id as number)
showToast('已删除长期计划及其每日任务')
delGoal.value = null
await load()
}
const date = ref(todayStr())
const items = ref<PlanItem[]>([])
const loading = ref(true)
const now = ref(Date.now())
let tick: number | null = null
const title = computed(() => formatDateCN(date.value))
const isToday = computed(() => date.value === todayStr())
async function load() {
loading.value = true
const [plans, gs, prog] = await Promise.all([
listPlansByDate(date.value),
listGoals(),
getGoalProgress()
])
items.value = plans
goals.value = gs
const map: Record<string, { done: number; total: number }> = {}
for (const p of prog) map[String(p.goalId)] = { done: p.done, total: p.total }
goalProg.value = map
loading.value = false
}
watch(date, load)
watch(attendanceChanged, load)
onMounted(() => {
load()
tick = window.setInterval(() => { now.value = Date.now() }, 30000)
})
onUnmounted(() => { if (tick) window.clearInterval(tick) })
function prevDay() { date.value = addDays(date.value, -1) }
function nextDay() { date.value = addDays(date.value, 1) }
function goToday() { date.value = todayStr() }
// ---- 汇总 ----
const summary = computed(() => {
const total = items.value.length
const done = items.value.filter(p => p.status === 'done').length
const plannedMin = items.value.reduce((acc, p) => acc + spanMinutes(p), 0)
const actualMin = items.value.reduce((acc, p) => acc + (p.actual_minutes || 0), 0)
const runningId = running.value
if (runningId) {
const rp = items.value.find(p => p.id === runningId)
if (rp) actualMin + elapsedMinutes(rp)
}
return { total, done, plannedMin, actualMin }
})
function spanMinutes(p: PlanItem): number {
const [sh, sm] = p.start_time.split(':').map(Number)
const [eh, em] = p.end_time.split(':').map(Number)
return Math.max(0, (eh * 60 + em) - (sh * 60 + sm))
}
function fmtMin(n: number): string {
if (n < 60) return n + ' 分钟'
return Math.floor(n / 60) + ' 小时 ' + (n % 60) + ' 分'
}
// ---- 计时时钟 ----
const running = computed(() => {
const r = items.value.find(p => !!p.timer_started_at)
return r ? (r.id as number) : null
})
function liveElapsed(p: PlanItem): number {
if (!p.timer_started_at) return p.actual_minutes || 0
const extra = Math.max(0, Math.round((now.value - new Date(p.timer_started_at).getTime()) / 60000))
return (p.actual_minutes || 0) + extra
}
async function toggleTimer(p: PlanItem) {
if (p.timer_started_at) {
await stopPlanTimer(p.id as number)
showToast('已停止计时:' + p.title)
} else {
await startPlanTimer(p.id as number)
showToast('开始计时:' + p.title)
}
await load()
}
// ---- 打卡 ----
async function mark(p: PlanItem, status: 'todo' | 'done' | 'skip') {
await setPlanStatus(p.id as number, status)
await load()
}
// ---- 新增 / 编辑 ----
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const fTitle = ref('')
const fKind = ref('学习')
const fStart = ref('09:00')
const fEnd = ref('10:00')
const fNote = ref('')
const fError = ref('')
const KINDS = ['学习', '健身', '自律', '工作', '阅读', '其他']
function openCreate() {
editingId.value = null
fTitle.value = ''
fKind.value = '学习'
fStart.value = '09:00'
fEnd.value = '10:00'
fNote.value = ''
fError.value = ''
dialogOpen.value = true
}
function openEdit(p: PlanItem) {
editingId.value = p.id as number
fTitle.value = p.title
fKind.value = p.kind
fStart.value = p.start_time
fEnd.value = p.end_time
fNote.value = p.note || ''
fError.value = ''
dialogOpen.value = true
}
async function submitPlan() {
if (!fTitle.value.trim()) { fError.value = '请填写计划内容'; return }
if (fEnd.value <= fStart.value) { fError.value = '结束时间需晚于开始时间'; return }
if (editingId.value) {
await updatePlan(editingId.value, {
title: fTitle.value.trim(), kind: fKind.value, start_time: fStart.value,
end_time: fEnd.value, note: fNote.value
})
showToast('计划已更新')
} else {
await addPlan({ date: date.value, start_time: fStart.value, end_time: fEnd.value, title: fTitle.value, kind: fKind.value, note: fNote.value })
showToast('计划已添加')
}
dialogOpen.value = false
await load()
}
async function remove(p: PlanItem) {
await deletePlan(p.id as number)
showToast('已删除')
await load()
}
const wdNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function weekday(s: string): string {
return wdNames[parseDateStr(s).getDay()]
}
</script>
<template>
<div class="plan-page">
<div class="card sec plan-head">
<div class="nav-row">
<button class="icon-btn" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
<div class="date-box">
<span class="date-main num">{{ date.slice(5) }} {{ weekday(date) }}</span>
<span class="muted date-sub num">{{ date }}</span>
</div>
<button class="icon-btn" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" :class="{ 'btn-primary': !isToday }" @click="goToday">今天</button>
<span class="flex1"></span>
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
</div>
<div class="muted sum-line">
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
<template v-if="running"> · <span class="timer-live">计时中</span></template>
</div>
</div>
<div class="goals-sec">
<div class="goals-head">
<span class="goals-title">长期计划</span>
<button class="btn btn-sm" @click="openGoalCreate"><Icon name="plus" :size="14" /> 新建长期计划</button>
</div>
<div v-if="!goals.length" class="muted goals-empty">暂无长期计划。可以创建一个跨越多天的目标(如「产品文档学习」),系统会自动生成每天的打卡任务。</div>
<div v-for="g in goals" :key="g.id" class="card sec goal-card">
<div class="goal-main">
<div class="goal-line">
<span class="tag">{{ g.kind }}</span>
<span class="goal-name">{{ g.title }}</span>
<span class="muted num goal-range">{{ g.start_date.slice(5) }} ~ {{ g.end_date.slice(5) }}</span>
</div>
<div class="goal-bar">
<span class="goal-bar-fill" :style="{ width: (goalProg[String(g.id)] ? Math.round(goalProg[String(g.id)].done / Math.max(1, goalProg[String(g.id)].total) * 100) : 0) + '%' }"></span>
</div>
<div class="muted num goal-meta">
{{ goalProg[String(g.id)] ? goalProg[String(g.id)].done + '/' + goalProg[String(g.id)].total + ' 天已完成' : '0/' + (goalProg[String(g.id)] ? goalProg[String(g.id)].total : 0) + ' 天' }}
<template v-if="g.note"> · {{ g.note }}</template>
</div>
</div>
<div class="goal-ops">
<button class="icon-btn" title="编辑" @click="openGoalEdit(g)"><Icon name="edit" :size="14" /></button>
<button class="icon-btn" title="删除" @click="delGoal = g"><Icon name="close" :size="14" /></button>
</div>
</div>
</div>
<div class="plan-list">
<div v-if="!loading && !items.length" class="card sec empty muted">
今天还没有计划,点右上角「添加计划」开始规划:学习、健身、自律……
</div>
<div
v-for="p in items" :key="p.id"
class="card sec plan-item"
:class="{ done: p.status === 'done', skip: p.status === 'skip', running: !!p.timer_started_at }"
>
<div class="pi-time num">
{{ p.start_time }}<span class="muted"> - {{ p.end_time }}</span>
</div>
<div class="pi-main">
<div class="pi-title">
<span class="tag">{{ p.kind }}</span>
<span v-if="p.goal_id" class="tag tag-goal">长期</span>
<span class="pi-text" :class="{ strike: p.status === 'done' }">{{ p.title }}</span>
<span v-if="p.status === 'done'" class="state-done">已完成</span>
<span v-else-if="p.status === 'skip'" class="state-skip">已跳过</span>
</div>
<div v-if="p.note" class="muted pi-note">{{ p.note }}</div>
<div class="muted pi-meta num">
<template v-if="p.timer_started_at">
<span class="timer-live">计时中 · 已 {{ liveElapsed(p) }} 分钟</span>
</template>
<template v-else-if="p.actual_minutes > 0">实际投入 {{ p.actual_minutes }} 分钟</template>
</div>
</div>
<div class="pi-ops">
<button
v-if="p.status !== 'done'" class="btn btn-xs"
:class="{ 'timer-on': !!p.timer_started_at }"
@click="toggleTimer(p)"
>{{ p.timer_started_at ? '停止计时' : '计时' }}</button>
<button v-if="p.status !== 'done'" class="btn btn-xs" @click="mark(p, 'done')">完成</button>
<button v-if="p.status === 'todo'" class="btn btn-xs" @click="mark(p, 'skip')">跳过</button>
<button v-if="p.status !== 'todo'" class="btn btn-xs" @click="mark(p, 'todo')">恢复</button>
<button class="icon-btn" title="编辑" @click="openEdit(p)"><Icon name="edit" :size="14" /></button>
<button class="icon-btn" title="删除" @click="remove(p)"><Icon name="close" :size="14" /></button>
</div>
</div>
</div>
<teleport to="body">
<div v-if="dialogOpen" class="fixed-mask" @click.self="dialogOpen = false">
<div class="dialog" role="dialog" aria-label="计划">
<div class="dialog-head">
<span class="dialog-title">{{ editingId ? '编辑计划' : '添加计划' }}</span>
<button class="icon-btn" @click="dialogOpen = false"><Icon name="close" :size="16" /></button>
</div>
<div class="dialog-body">
<div class="form-row">
<label>计划内容(学什么、做什么)</label>
<input class="input" v-model="fTitle" placeholder="如:产品文档第三章 / 晨跑 5 公里" @keydown.enter="submitPlan" />
</div>
<div class="form-row">
<label>类别</label>
<select class="select" v-model="fKind">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="form-row two">
<div>
<label>开始时间</label>
<input class="input" type="time" v-model="fStart" />
</div>
<div>
<label>结束时间</label>
<input class="input" type="time" v-model="fEnd" />
</div>
</div>
<div class="form-row">
<label>备注(选填)</label>
<input class="input" v-model="fNote" placeholder="目标、资料、提醒等" @keydown.enter="submitPlan" />
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="dialogOpen = false">取消</button>
<button class="btn btn-primary btn-sm" @click="submitPlan">{{ editingId ? '保存' : '添加' }}</button>
</div>
</div>
</div>
</teleport>
<teleport to="body">
<div v-if="goalOpen" class="fixed-mask" @click.self="goalOpen = false">
<div class="dialog" role="dialog" aria-label="长期计划">
<div class="dialog-head">
<span class="dialog-title">{{ goalEditing ? '编辑长期计划' : '新建长期计划' }}</span>
<button class="icon-btn" @click="goalOpen = false"><Icon name="close" :size="16" /></button>
</div>
<div class="dialog-body">
<div class="form-row">
<label>计划名称(落实为每天一条任务)</label>
<input class="input" v-model="gTitle" placeholder="如:产品文档学习 / 健身月计划" />
</div>
<div class="form-row">
<label>类别</label>
<select class="select" v-model="gKind">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="form-row two">
<div>
<label>开始日期</label>
<input class="input" type="date" v-model="gStart" />
</div>
<div>
<label>结束日期</label>
<input class="input" type="date" v-model="gEnd" />
</div>
</div>
<div class="form-row">
<label>备注(选填)</label>
<input class="input" v-model="gNote" />
</div>
<div v-if="gError" class="form-error">{{ gError }}</div>
<div class="muted goal-hint">创建后区间内每一天都会生成一条任务,可在每日安排中逐日打卡与计时。</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="goalOpen = false">取消</button>
<button class="btn btn-primary btn-sm" @click="submitGoal">{{ goalEditing ? '保存' : '创建' }}</button>
</div>
</div>
</div>
</teleport>
<ConfirmDialog
:open="!!delGoal"
title="删除长期计划"
:message="delGoal ? '将删除「' + delGoal.title + '」及其全部每日任务(含已完成记录),不可恢复。' : ''"
confirm-text="删除" danger
@cancel="delGoal = null"
@confirm="removeGoal"
/>
</div>
</template>
<style scoped>
.plan-page { display: flex; flex-direction: column; gap: 10px; max-width: 760px; }
.plan-head { padding: 12px 16px; }
.nav-row { display: flex; align-items: center; gap: 8px; }
.date-box { min-width: 150px; text-align: center; }
.date-main { font-weight: 600; font-size: 14px; }
.date-sub { font-size: 11px; margin-left: 6px; }
.sum-line { font-size: 12px; margin-top: 8px; }
.timer-live { color: var(--accent); font-weight: 600; }
.flex1 { flex: 1; }
.plan-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; }
.plan-item.done { opacity: 0.62; }
.plan-item.skip { opacity: 0.5; }
.plan-item.running { border-left: 3px solid var(--accent); }
.pi-time { font-size: 13px; font-weight: 600; min-width: 92px; padding-top: 2px; }
.pi-main { flex: 1; min-width: 0; }
.pi-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pi-text { font-size: 14px; font-weight: 500; word-break: break-all; }
.pi-text.strike { text-decoration: line-through; }
.state-done { color: var(--ok, #2e9e5b); font-size: 12px; }
.state-skip { color: var(--text-3); font-size: 12px; }
.pi-note { font-size: 12px; margin-top: 3px; }
.pi-meta { font-size: 12px; margin-top: 3px; }
.pi-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.timer-on { color: var(--accent); border-color: var(--accent); }
.empty { text-align: center; padding: 26px 16px; }
.fixed-mask {
position: fixed; inset: 0; z-index: 80;
background: rgba(29, 33, 41, 0.35);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.dialog { background: var(--bg-page); border-radius: 12px; width: 420px; max-width: 100%; }
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
.dialog-title { font-weight: 600; font-size: 14px; }
.dialog-body { padding: 6px 16px 4px; }
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 16px 16px; }
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
.form-row label { font-size: 12px; color: var(--text-2); }
.form-row.two { flex-direction: row; gap: 10px; }
.form-row.two > div { flex: 1; display: flex; flex-direction: column; gap: 5px; }
@media (max-width: 720px) {
.plan-item { flex-wrap: wrap; }
.pi-ops { width: 100%; justify-content: flex-start; }
}
</style>
+102 -21
View File
@@ -7,14 +7,14 @@ import {
listAttendanceBetween, listLogItemsBetween, listProjects,
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
} from '../db/repo'
import { categoryLabel, type Project } from '../db/schema'
import { type Project } from '../db/schema'
import { getLogCategories, setLogCategories, getLedgerCategories, setLedgerCategories } from '../db/categories'
import { downloadText, } from '../lib/download'
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 { ensureCloudBinding, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
import { getCloudCreds } from '../db/auth'
import { showToast } from '../lib/toast'
@@ -28,6 +28,50 @@ const aiKey = ref('')
const aiModel = ref('')
const editingId = ref<number | null>(null)
const editName = ref('')
// ---- 分类管理 ----
const logCats = ref<string[]>([])
const expenseCats = ref<string[]>([])
const incomeCats = ref<string[]>([])
const editingCat = ref<{ kind: 'log' | 'expense' | 'income'; index: number; value: string } | null>(null)
async function loadCategories() {
logCats.value = await getLogCategories()
expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income')
}
async function addCat(kind: 'log' | 'expense' | 'income') {
const name = window.prompt('新分类名称')
if (!name || !name.trim()) return
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
const clean = name.trim()
if (list.includes(clean)) { showToast('分类已存在'); return }
const next = [...list, clean]
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
showToast('分类已添加')
}
async function renameCat(kind: 'log' | 'expense' | 'income', index: number, oldName: string) {
const name = window.prompt('修改分类名称', oldName)
if (!name || !name.trim() || name.trim() === oldName) return
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
const next = list.map((x, i) => (i === index ? name.trim() : x))
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
showToast('分类已更新(历史记录自动跟随显示)')
}
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
if (list.length <= 1) { showToast('至少保留一个分类'); return }
if (!window.confirm('删除分类「' + list[index] + '」?历史记录不受影响。')) return
const next = list.filter((_, i) => i !== index)
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
showToast('分类已删除')
}
const fileInput = ref<HTMLInputElement | null>(null)
const pendingImport = ref<{ name: string; text: string } | null>(null)
const importError = ref('')
@@ -38,7 +82,6 @@ const newLoginPwd2 = ref('')
const accError = ref('')
const deleteAccConfirm = ref(false)
const appVersion = __APP_VERSION__
const syncBase = ref('')
const cloudUser = ref('')
const syncStatus = ref('')
const syncError = ref('')
@@ -47,7 +90,6 @@ const syncing = ref(false)
const lastSync = ref('')
async function loadSyncInfo() {
syncBase.value = await getSyncBase()
cloudUser.value = getCloudCreds()?.username || ''
lastSync.value = await getSyncLastTime()
autoSyncErr.value = syncLastError.value
@@ -55,11 +97,6 @@ async function loadSyncInfo() {
watch(syncLastError, v => { autoSyncErr.value = v })
async function saveSyncConfig() {
await setSyncBaseOverride(syncBase.value.trim())
showToast('同步地址已保存')
}
async function rebindCloud() {
syncError.value = ''
try {
@@ -76,7 +113,6 @@ async function doSyncNow() {
autoSyncErr.value = syncLastError.value
syncing.value = true
try {
await saveSyncConfig()
const result: SyncResult = await syncWithStoredConfig()
lastSync.value = result.at
syncStatus.value = '推送 ' + result.pushed + ' 条 · 拉取 ' + result.pulled + ' 条 · 删除 ' + result.removed + ' 条'
@@ -118,6 +154,7 @@ async function load() {
aiKey.value = await getSetting('ai_api_key', '')
aiModel.value = await getSetting('ai_model', '')
await loadSyncInfo()
await loadCategories()
}
async function saveAi() {
@@ -263,7 +300,7 @@ async function doImport() {
<div class="settings">
<div class="card section">
<div class="section-title">账号与隐私</div>
<div class="muted acc-line">版本 v{{ appVersion }} · 当前用户:{{ username }} · 每位用户独立数据库隔离存储于本机 · 登录密码仅保存 PBKDF2 哈希</div>
<div class="muted acc-line">v{{ appVersion }} · 当前账号 {{ username }} · 登录密码仅存 PBKDF2 哈希</div>
<div class="ai-grid">
<div class="rule-item">
<label>旧登录密码</label>
@@ -352,15 +389,10 @@ async function doImport() {
<div class="card section">
<div class="section-title">多端数据同步</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" />
</div>
<div class="muted acc-line">云端账号:{{ cloudUser || '未绑定' }} · 数据存于服务器,其他端变更秒级自动刷新</div>
<div class="rule-save">
<button class="btn btn-sm" @click="saveSyncConfig">保存地址</button>
<button class="btn btn-primary btn-sm" :disabled="syncing" @click="doSyncNow">{{ syncing ? '同步中' : '立即同步' }}</button>
<button class="btn btn-sm" @click="rebindCloud">重建云端绑定</button>
<button class="btn btn-sm" @click="rebindCloud">重新校验连接</button>
</div>
<div v-if="syncStatus" class="muted acc-line">上次同步:{{ lastSync || '--' }} · {{ syncStatus }}</div>
<div v-if="syncError" class="form-error">{{ syncError }}</div>
@@ -385,7 +417,41 @@ async function doImport() {
</div>
<div class="rule-save">
<button class="btn btn-primary btn-sm" @click="saveAi">保存 AI 配置</button>
<span class="muted loc-note">配置仅保存在本机浏览器;总结页可用 AI 润色草稿,需服务端允许浏览器跨域</span>
<span class="muted loc-note">配置仅保存在本机;总结页「AI 润色」需此处先配置</span>
</div>
</div>
<div class="card section">
<div class="section-title">工作分类</div>
<div class="cat-block">
<div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="addCat('log')">添加</button></div>
<div class="cat-chips">
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('log', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('log', i)">删</button>
</span>
</div>
</div>
<div class="cat-block">
<div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="addCat('expense')">添加</button></div>
<div class="cat-chips">
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('expense', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('expense', i)">删</button>
</span>
</div>
</div>
<div class="cat-block">
<div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="addCat('income')">添加</button></div>
<div class="cat-chips">
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('income', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('income', i)">删</button>
</span>
</div>
</div>
</div>
@@ -402,7 +468,7 @@ async function doImport() {
/>
</div>
<div v-if="importError" class="import-error">{{ importError }}</div>
<div class="muted backup-note">数据保存在浏览器 IndexedDB 中,建议定期导出 JSON 备份。</div>
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
</div>
<ConfirmDialog
@@ -448,6 +514,21 @@ async function doImport() {
.loc-note { font-size: 12px; }
.ai-grid { display: flex; flex-direction: column; gap: 10px; }
.backup-row { display: flex; gap: 8px; }
.cat-block { margin-top: 10px; }
.cat-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
.cat-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.cat-chip {
display: inline-flex; align-items: center; gap: 4px;
border: 1px solid var(--border); border-radius: 999px;
padding: 2px 8px; font-size: 12px;
}
.chip-btn { border: none; background: transparent; color: var(--text-3); cursor: pointer; font-size: 11px; padding: 0 1px; }
.chip-btn:hover { color: var(--accent); }
.exp-dates { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.exp-dates .input { width: 150px; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 5px 12px; }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.backup-note { font-size: 12px; margin-top: 10px; }
</style>
+10 -5
View File
@@ -2,7 +2,7 @@
import { onMounted, ref, watch } from 'vue'
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects } from '../db/repo'
import { computeDay } from '../domain/attendance'
import { CATEGORIES } from '../db/schema'
import { getLogCategories } from '../db/categories'
import { addDays, todayStr, weekStart } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
@@ -57,10 +57,15 @@ async function loadAll(): Promise<void> {
.sort((a, b) => b.count - a.count)
const total = items.length || 1
catStats.value = CATEGORIES
.map(c => {
const count = items.filter(i => i.category === c.key).length
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
const logCats = await getLogCategories()
const catCount = new Map<string, number>()
for (const it of items) catCount.set(it.category, (catCount.get(it.category) || 0) + 1)
const catSet = new Set(logCats)
for (const k of catCount.keys()) if (!catSet.has(k)) logCats.push(k)
catStats.value = logCats
.map(label => {
const count = catCount.get(label) || 0
return { key: label, label, count, percent: Math.round((count / total) * 100) }
})
.filter(c => c.count > 0)
.sort((a, b) => b.count - a.count)
+97 -1
View File
@@ -2,7 +2,12 @@
import { computed, ref } from 'vue'
import Icon from '../components/Icon.vue'
import DayPanel from '../components/log/DayPanel.vue'
import { addDays, formatDateCN, todayStr } from '../lib/date'
import { listLogItemsBetween, listAttendanceBetween, listProjects, getWorkRule } from '../db/repo'
import { computeDay } from '../domain/attendance'
import { addDays, formatDateCN, hhmm, todayStr, weekStart } from '../lib/date'
import { downloadText } from '../lib/download'
import { showToast } from '../lib/toast'
import { categoryLabel } from '../db/schema'
// 详情视图:自由切换日期,对任意一天进行详细查看与修改
const date = ref(todayStr())
@@ -11,6 +16,73 @@ const isToday = computed(() => date.value === todayStr())
function prevDay() { date.value = addDays(date.value, -1) }
function nextDay() { date.value = addDays(date.value, 1) }
function goToday() { date.value = todayStr() }
// ---- 工作详情导出(Markdown) ----
const expRange = ref<'day' | 'week' | 'month' | 'custom'>('day')
const expStart = ref(todayStr())
const expEnd = ref(todayStr())
const exporting = ref(false)
function rangeStartEnd(): { start: string; end: string; label: string } {
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
if (expRange.value === 'week') {
const ws = weekStart(expStart.value)
return { start: ws, end: addDays(ws, 6), label: ws + '_' + addDays(ws, 6) }
}
if (expRange.value === 'month') {
const y = expStart.value.slice(0, 4)
const m = expStart.value.slice(5, 7)
const last = new Date(Number(y), Number(m), 0).getDate()
return { start: y + '-' + m + '-01', end: y + '-' + m + '-' + last, label: y + '-' + m }
}
return { start: expStart.value, end: expEnd.value, label: expStart.value + '_' + expEnd.value }
}
async function doExportMd() {
if (exporting.value) return
exporting.value = true
try {
const { start, end, label } = rangeStartEnd()
const [rule, atts, items, projects] = await Promise.all([
getWorkRule(),
listAttendanceBetween(start, end),
listLogItemsBetween(start, end),
listProjects(true)
])
const pname = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
const lines: string[] = ['# 工作详情 ' + label, '']
const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
let cur = start
let guard = 0
while (cur <= end && guard < 400) {
const dayItems = items.filter(i => i.date === cur)
const att = atts.find(a => a.date === cur)
if (att || dayItems.length) {
lines.push('## ' + cur + ' ' + wd[new Date(cur + 'T00:00:00').getDay()], '')
if (att && (att.clock_in_at || att.kind === 'makeup')) {
const info = computeDay(att, cur, rule, cur === todayStr())
const seg = [hhmm(att.clock_in_at), '–', hhmm(att.clock_out_at), '·', info.label]
if (att.location) seg.push('· 地点:' + att.location)
lines.push('考勤:' + seg.join(' '), '')
}
for (const it of dayItems) {
const proj = it.project_id ? (pname.get(it.project_id) || '') : ''
const head = '- 【' + categoryLabel(it.category) + '】' + it.content + (it.minutes ? '(' + it.minutes + ' 分钟)' : '') + (proj ? ' @' + proj : '')
lines.push(head)
if (it.takeaway) lines.push(' - 收获:' + it.takeaway)
if (it.needs_followup === 1 && it.remark) lines.push(' - 待跟进:' + it.remark)
else if (it.remark) lines.push(' - 备注:' + it.remark)
}
lines.push('')
}
cur = addDays(cur, 1)
guard++
}
await downloadText('工作详情_' + label + '.md', lines.join('\n'))
showToast('已导出 Markdown')
} finally {
exporting.value = false
}
}
function onDateInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (v) date.value = v
@@ -26,6 +98,18 @@ function onDateInput(e: Event) {
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
</div>
<div class="export-row">
<span class="muted exp-label">导出工作详情</span>
<div class="seg">
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">当日</button>
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>
</div>
<input v-if="expRange !== 'day'" class="input w-exp" type="date" v-model="expStart" />
<input v-if="expRange === 'custom'" class="input w-exp" type="date" v-model="expEnd" />
<button class="btn btn-sm" :disabled="exporting" @click="doExportMd">导出 Markdown</button>
</div>
<DayPanel :date="date" />
</div>
</template>
@@ -42,6 +126,18 @@ function onDateInput(e: Event) {
.date-title.is-today { color: var(--accent); }
.w-date { width: 150px; }
.export-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 12px; padding: 8px 12px;
border: 1px solid var(--border); border-radius: var(--radius-md);
background: var(--bg-page);
}
.exp-label { font-size: 12px; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 4px 10px; }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.w-exp { width: 140px; }
@media (max-width: 720px) {
.detail-toolbar { gap: 8px; }
.date-title { min-width: 0; flex: 1; text-align: left; }
+16 -1
View File
@@ -62,7 +62,22 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
- 数据目录:/opt/daywork/server/data
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
- users/<id>.json 每用户九张表数据 + 墓碑
- backups/<日期>/ **每日自动备份**(启动与每次注册表变更也会触发,保留最近 14 天)
- backups/<日期>/ **每日自动本地备份**(启动与每次注册表变更也会触发,保留最近 14 天)
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
environment:
COS_SECRET_ID: <你的 SecretId>
COS_SECRET_KEY: <你的 SecretKey>
COS_BUCKET: <桶名-AppId,如 footprint-1435255581>
COS_REGION: <地域,如 ap-guangzhou>
开启后每次本地备份都会同步上传(含 latest/ 覆盖版);上传状态可随时查看:
curl https://daywk.linyux.cn/api/health
返回 backup: "ok" / "failed: ..." / "local-only"(未配置)。
- 会话安全:修改密码或管理员重置密码后,该用户**所有旧登录令牌立即失效**(各端自动用保存的凭据重新登录,用户无感知)
- 登录防爆破:同一 IP 十分钟内密码错误满 10 次将临时拒绝(429)
- 更新版本:cd /opt/daywork && git pull && cd server && docker compose up -d --build
- 同步协议:POST /api/sync(Bearer token;推送差异并返回权威全量快照)
- 实时通知:GET /api/events?token=...(SSE,同账号任意端有变更即广播 change)
+97 -10
View File
@@ -12,7 +12,7 @@ const PORT = Number(process.env.PORT || 3780)
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
const ITER = 150000
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
const NATURAL_KEYS = {
@@ -39,6 +39,69 @@ function writeJson(file, data) {
fs.renameSync(tmp, path.join(DATA_DIR, file))
}
// ---------- 异地备份:可选,配置环境变量后每日备份与注册表变更时自动上传 COS ----------
// COS_SECRET_ID / COS_SECRET_KEY / COS_BUCKET(形如 name-125xxxxxx) / COS_REGION(形如 ap-guangzhou)
const COS_CFG = {
secretId: process.env.COS_SECRET_ID || '',
secretKey: process.env.COS_SECRET_KEY || '',
bucket: process.env.COS_BUCKET || '',
region: process.env.COS_REGION || ''
}
let lastOffsiteBackup = { state: 'disabled', at: '', detail: '' }
function cosEncode(s) {
return encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
}
function cosAuth(method, pathname) {
const start = Math.floor(Date.now() / 1000) - 60
const end = start + 600
const keyTime = start + ';' + end
const signKey = crypto.createHmac('sha1', COS_CFG.secretKey).update(keyTime).digest('hex')
const lowerPath = pathname.toLowerCase()
const httpString = method.toLowerCase() + '\n' + cosEncode(lowerPath) + '\n' + '' + '\n' + 'host=' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + '\n'
const stringToSign = crypto.createHash('sha1').update(httpString).digest('hex') + '\n' + signKey + '\n' + keyTime + '\n' + crypto.createHash('sha1').update('host').digest('hex')
const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex')
return 'q-sign-algorithm=sha1&q-ak=' + COS_CFG.secretId + '&q-sign-time=' + keyTime + '&q-key-time=' + keyTime + '&q-header-list=host&q-url-param-list=&q-signature=' + signature
}
async function cosPut(key, filePath) {
const body = fs.readFileSync(filePath)
const pathname = '/' + key.split('/').map(cosEncode).join('/')
const auth = cosAuth('put', pathname)
const url = 'https://' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + pathname
const resp = await fetch(url, {
method: 'PUT',
headers: {
Host: COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com',
Authorization: auth,
'Content-Length': String(body.length)
},
body
})
if (!resp.ok) throw new Error('COS ' + resp.status + ' ' + (await resp.text()).slice(0, 120))
}
async function backupOffsite() {
if (!COS_CFG.secretId || !COS_CFG.secretKey || !COS_CFG.bucket || !COS_CFG.region) return
try {
const day = new Date().toISOString().slice(0, 10)
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const files = [['registry.json', 'registry.json'], ['secret.key', 'secret.key']]
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) if (f.endsWith('.json')) files.push(['users/' + f, 'users/' + f])
}
} catch { /* 忽略 */ }
for (const [rel, src] of files) {
const full = path.join(DATA_DIR, src)
if (!fs.existsSync(full)) continue
await cosPut('daywork-backup/' + day + '/' + stamp + '/' + rel, full)
await cosPut('daywork-backup/latest/' + rel, full)
}
lastOffsiteBackup = { state: 'ok', at: new Date().toISOString(), detail: files.length + ' files' }
} catch (e) {
lastOffsiteBackup = { state: 'failed', at: new Date().toISOString(), detail: (e && e.message) || String(e) }
console.error('offsite backup failed:', e && e.message)
}
}
if (!fs.existsSync(path.join(DATA_DIR, 'secret.key'))) {
writeJson('secret.key', { secret: crypto.randomBytes(32).toString('hex') })
}
@@ -55,7 +118,7 @@ for (const u of registry.users) {
if (adminDirty) saveRegistry()
function isAdminToken(token) {
const auth = verifyToken(token)
if (!auth) return null
if (!auth || !tokenUsable(auth)) return null
const rec = registry.users.find(u => u.id === auth.userId)
if (!rec || rec.role !== 'admin') return null
return rec
@@ -76,7 +139,7 @@ function pbkdf2Hash(password, salt) {
return crypto.pbkdf2Sync(password, salt, ITER, 32, 'sha256').toString('hex')
}
function makeToken(userId) {
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS }))
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS, iat: Date.now() }))
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url')
return payload + '.' + sig
}
@@ -95,12 +158,26 @@ function verifyToken(token) {
return null
}
}
// 会话时效:改密 / 管理员重置密码后,签发时间早于 pwd_changed_at 的令牌一律失效
function tokenUsable(payload) {
if (!payload || !payload.userId) return false
const rec = registry.users.find(u => u.id === payload.userId)
if (!rec) return false
if (rec.pwd_changed_at) {
const since = Date.parse(rec.pwd_changed_at)
if (!payload.iat || payload.iat < since) return false
}
return true
}
function base64url(s) {
return Buffer.from(s).toString('base64url')
}
// ---------- SSE 长连接:按用户分组,数据变更时实时通知 ----------
const sseClients = new Map()
// 登录防爆破:IP -> { count, first }(内存态,重启即清)
const loginFails = new Map()
function broadcast(userId, event) {
const set = sseClients.get(userId)
if (!set) return
@@ -189,12 +266,12 @@ async function handle(req, res) {
return
}
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true }); return }
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); return }
// SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头)
if (req.method === 'GET' && route === '/api/events') {
const auth = verifyToken(url.searchParams.get('token'))
if (!auth) { json(res, 401, { error: '未认证' }); return }
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@@ -213,7 +290,7 @@ async function handle(req, res) {
// 修改登录密码(验证旧密码后重置盐与哈希)
if (req.method === 'POST' && route === '/api/auth/password') {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth) { json(res, 401, { error: '未认证' }); return }
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const body = await readBody(req)
const rec = registry.users.find(u => u.id === auth.userId)
if (!rec) { json(res, 404, { error: '用户不存在' }); return }
@@ -221,7 +298,7 @@ async function handle(req, res) {
if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '新密码至少 6 位' }); return }
rec.pwd_salt = crypto.randomBytes(16).toString('hex')
rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt)
rec.pwd_changed_at = new Date().toISOString()
rec.pwd_changed_at = new Date().toISOString() // 吊销该用户全部旧令牌
saveRegistry()
json(res, 200, { ok: true })
return
@@ -230,7 +307,7 @@ async function handle(req, res) {
// 删除云端账号:注册表与全部业务数据一并清除(不可恢复,客户端已做二次确认)
if (req.method === 'POST' && route === '/api/auth/delete') {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth) { json(res, 401, { error: '未认证' }); return }
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const idx = registry.users.findIndex(u => u.id === auth.userId)
if (idx >= 0) { registry.users.splice(idx, 1); saveRegistry() }
try { fs.rmSync(userFile(auth.userId), { force: true }) } catch { /* 忽略 */ }
@@ -256,18 +333,27 @@ async function handle(req, res) {
}
if (req.method === 'POST' && route === '/api/auth/login') {
const loginIp = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unknown'
const nowMs = Date.now()
let st = loginFails.get(loginIp)
if (st && nowMs - st.first < 10 * 60 * 1000 && st.count >= 10) { json(res, 429, { error: '尝试次数过多,请 10 分钟后再试' }); return }
const body = await readBody(req)
const username = String(body.username || '').trim()
const rec = registry.users.find(u => u.username === username)
if (!rec) { json(res, 401, { error: '用户不存在' }); return }
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) { json(res, 401, { error: '密码不正确' }); return }
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) {
const s2 = loginFails.get(loginIp)
loginFails.set(loginIp, !s2 || nowMs - s2.first >= 10 * 60 * 1000 ? { count: 1, first: nowMs } : { count: s2.count + 1, first: s2.first })
json(res, 401, { error: '密码不正确' }); return
}
loginFails.delete(loginIp)
json(res, 200, { userId: rec.id, username: rec.username, token: makeToken(rec.id) })
return
}
if (req.method === 'POST' && route === '/api/sync') {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth) { json(res, 401, { error: '未认证' }); return }
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const body = await readBody(req)
const userData = loadUserData(auth.userId)
if (!userData.cursor) userData.cursor = 0
@@ -444,6 +530,7 @@ function doBackup() {
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
} catch (e) { console.error('backup failed:', e && e.message) }
void backupOffsite()
}
doBackup()
setInterval(doBackup, 24 * 60 * 60 * 1000)