12 Commits
Author SHA1 Message Date
root 5a6ab2b322 ui: 全组件审核打磨(按下态/细滚动条/tooltip修复/统计卡语义色条/进度条完成态/危险确认警示/耗时chips/日历今日高亮/密码等宽/登录页质感) 2026-10-26 21:48:33 +08:00
root 3b48a27921 fix: 服务端表结构自愈(数组/非法行治愈为uuid键对象,根因修复500与重启丢数据) + 客户端快照防御 2026-10-26 21:34:03 +08:00
root 793a41ab99 fix: 写后同步失败不再弹全屏报错(降级为状态记录+自动续传) / 服务端500异常写入容器日志留痕 2026-10-26 21:21:25 +08:00
root 7d11a48756 ui: 移动端触控基线与板块重排(按钮32-36/输入38/弹窗按钮40;出勤2x2;计划操作等宽;记账工具栏分区;导出铺满;底栏56) 2026-10-26 21:07:50 +08:00
root 1afc236d88 storage: 服务端存储加固(原子落盘fsync/损坏隔离/墓碑14天GC/空同步不落盘/当日首份快照不可变/每周深快照/大批删除前置快照/health增强) 2026-10-26 20:51:12 +08:00
root d48330e5c4 fix: 二道防线(服务器行数少于镜像即判定数据丢失,自动全量重推自救,首次升级即可生效) + 灾难恢复冒烟7项 2026-10-26 19:58:59 +08:00
root dc5ab15ae1 fix: 服务器数据纪元保护(空快照不清空本地,自动全量重推自救) / 详情页导出指定日 / 全局计时时钟面板 2026-10-26 17:32:34 +08:00
root 7a36fd4e04 ui: 全站界面重设计(文字侧栏+灰底白卡层次+组件统一) fix: 添加记录弹窗不显示/计时汇总崩溃/CSV导出崩溃/记账切换类型崩溃 2026-10-26 12:40:54 +08:00
root 0f268a495e feat: 长期计划(目标→每日任务物化+进度条) / 详情页Markdown范围导出 / 记账与详情页改内容优先弹窗新增 2026-10-26 12:16:14 +08:00
root 284f8ebd46 feat: 计划模式(时间段+打卡+计时时钟) / 工作详情范围导出 / 工作与记账分类自定义(含历史迁移) 2026-10-26 11:48:14 +08:00
root a1f2a45623 ui: 侧边栏按“记录/工具/分析/系统”重排并分组分隔;精简设置/登录/记账页冗余文案(修正旧架构遗留说明);键盘焦点态 2026-10-17 11:07:04 +08:00
root 08b0317d35 security: 改密即吊销全部旧令牌 + 登录防爆破 + COS 异地备份(环境变量开关) + 启动失败重试按钮 2026-10-17 10:59:25 +08:00
55 changed files with 2753 additions and 963 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
+29
View File
@@ -0,0 +1,29 @@
# v0.6.0 发布说明(2026-10-26)
## 界面整体重设计
本版本对全部界面做了系统性改版,统一为「高级白」企业级视觉,信息层次更清晰:
- **桌面侧栏重做**:由纯图标改为「图标 + 文字」导航,按 记录 / 工具 / 分析 / 系统 分组;新增品牌区与当前用户区(头像、用户名、退出)。
- **顶栏精简**:只保留页面标题、全局搜索与打卡按钮;用户信息与退出移入侧栏底部。
- **内容层次**:内容区改为浅灰底 + 白色卡片,卡片带细边框与轻投影,主次分明。
- **组件统一**:按钮、输入框、表格、弹窗、分段选择器、进度条等全部收敛为全局统一样式,各页面不再各自为政。
- **逐页重排**:日历(网格线 + 选中描边)、详情(导航与导出合并为一条工具栏)、计划(长期计划与每日安排分区块、列表合并为单卡)、记账、统计(热力图改品牌蓝)、总结、备忘录、密码库、设置、用户管理。
- **登录页升级**:品牌标识、卡片投影。
- **平板适配**:721–1000px 侧栏自动折叠为图标栏。
- **移动端**:底部标签栏与「更多」弹层沿用,并随新基座精修间距与弹窗。
## 问题修复
- 修复:详情页「添加记录」按钮点击无反应(弹窗开关与编辑目标状态混用所致)。
- 修复:计划页有任务正在计时时,汇总行会触发未定义函数导致整页报错。
- 修复:设置页导出工作记录 CSV 缺少分类标签导入,触发即报错。
- 修复:记账弹窗切换收支类型时引用了不存在的分类常量,触发即报错。
- 记录弹窗标题与按钮文案随「新增 / 编辑」模式正确切换。
## 升级与部署
- 桌面端:安装 InternLog-Setup-0.6.0.exe。
- 安卓端:安装 InternLog-0.6.0-debug.apk。
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端 bundle 随仓库更新)。
- 数据结构无变更,v0.5.0 及之前版本可直接升级。
+18
View File
@@ -0,0 +1,18 @@
# v0.6.1 发布说明(2026-10-26)
## 数据安全:服务器数据纪元保护(重要)
- 服务端为每个用户的数据文件记录「数据纪元」(首次创建时间),随同步接口返回。
- 客户端检测到纪元变化(说明服务器数据存储被重建,例如误删 data 目录后重启)时,自动作废本地同步镜像,**把本机全量数据重新推回服务器**,避免「服务器空了 → 拉到空快照 → 本地缓存也被清空」的连锁数据丢失。
- 也就是说:只要还有任何一台设备保留着本地缓存,升级到本版本后第一次同步就会把数据恢复到服务器。
- 服务器部署文档已移除危险的 `rm -rf data` 步骤,并明确标注 data 目录为正式数据、严禁删除。
## 功能修正
- 详情页导出:「当日」改为「指定日」——导出的是详情页正在浏览的那一天;切换浏览日期时,导出区间(周/月/自定义)锚点自动跟随。
- 新增**全局计时时钟面板**:计划开始计时后,任何页面右下角都会悬浮显示计时任务(脉冲动画 + 实时走秒 + 一键停止),点击可跳回计划页;手机端悬浮于底部标签栏上方。
## 升级
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(**切勿删除 server/data 目录**)
- 桌面 / 安卓:安装对应 0.6.1 安装包。
+18
View File
@@ -0,0 +1,18 @@
# v0.6.2 发布说明(2026-10-26)
## 数据安全:补上「首次升级即遇灾」的恢复漏洞(关键)
- v0.6.1 的数据纪元保护只对「已存过纪元」的设备生效;所有设备第一次跑新版时检测不到服务器数据被重建。
- 本版本增加**二道防线**(不依赖任何历史状态):若本地镜像认为服务器应有 N 行,而服务器实际行数更少——即服务器数据发生过丢失/重建——立即作废本地镜像,把本机全量数据重推回服务器,再拉取快照,本地与服务器同时恢复。
- 已通过灾难恢复冒烟测试(7/7):删除服务器用户数据文件后,保留缓存的设备下一次同步自动重推全部数据(备忘录、密码库密文、主密码盐与校验值、考勤规则),全新设备随后完整拉回。
## 与数据丢失相关的说明
- 密码库:密文行与主密码盐/校验值均随同步存储于服务器;只要任一快照存活即可整体恢复,主密码不变。
- 备忘录:明文行随同步存储,恢复机制相同。
- 若所有设备都已在数据丢失后打开过应用(本地缓存同样被旧版清空),且服务器 data 目录被删、COS 未配置,则丢失部分无法找回;建议尽快配置 COS 异地备份。
## 升级
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(切勿删除 server/data)
- 桌面 / 安卓:安装 0.6.2;网页端刷新页面即为新版。
+26
View File
@@ -0,0 +1,26 @@
# v0.6.3 发布说明(2026-10-26)
服务端存储层加固(纯服务端变更,客户端无需重装;桌面/安卓继续使用 0.6.2 安装包)。
## 存储可靠性
- **原子落盘**:所有 JSON 写入改为「临时文件 + fsync + 原子改名」,进程崩溃/断电不会留下半截数据文件
- **损坏隔离**:用户数据文件若解析失败,自动隔离为 `.corrupt-<时间戳>` 保留原件并在日志报错——绝不静默当作空数据(旧逻辑的静默空文件正是数据事故的放大器)
- **墓碑自动回收**:删除标记保留 14 天后自动清除,数据文件不再随历史无限膨胀,也不会永久重放旧删除
- **空同步不落盘**:内容无变化的同步不再重写磁盘(顺带修复 cursor 假值导致的每轮必写)
## 备份体系
- **当日首份快照不可变**:同日再备份写入带时间戳的副本目录,当日证据链不再被覆盖(本次事故中 11:11 快照被 20:03 覆盖正是教训)
- **每周深快照**:每周一额外保留一份,保留 8 份
- **大批删除前置保险**:单次同步携带 ≥20 条墓碑时,服务端先自动快照再应用删除,可随时回滚
- `/api/health` 新增 users / lastBackup 字段
## 验证
- 服务端协议审计 36/36、用户管理审计 16/16、灾难恢复自救 7/7、存储加固冒烟 9/9 全部通过
- 修复审计中两处过时预期(墓碑 GC 后老日期墓碑不再回传属预期;当日首份快照布局变更)
## 部署
`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
+23
View File
@@ -0,0 +1,23 @@
# v0.6.4 发布说明(2026-10-26)
## 移动端布局与板块尺寸优化
**触控基线**(全部界面统一):
- 常规按钮 36px / 次级 32px / 小按钮 30px(原 24-28px,触控不友好)
- 输入框与下拉 38px 高;分段选择器 34px;图标按钮 34px
- 弹窗底部主按钮 40px 并等宽铺满;弹窗宽距收窄近全屏
**板块重排**:
- 详情页:出勤信息改 2×2 紧凑网格(原手机上拉成 1 列过高);打卡/休假按钮并排等宽
- 计划页:每日任务操作按钮(计时/完成/跳过)等宽铺满、触控高度 32px;「今天 / 添加计划」等宽;长期计划卡操作角标化
- 记账页:工具栏分区——月份导航一行、两个筛选并排、「记一笔」全宽主按钮
- 详情页导出:指定日/当周/当月/自定义分段等宽铺满,日期输入全宽
- 总结页:范围分段铺满一行;草稿编辑器高度收窄
- 备忘录:列表收窄至 210px,底部按钮等宽
- 底部标签栏加高至 56px、图标 22px;悬浮计时时钟位置同步适配
- 顶栏打卡按钮加高至 34px,更易点按
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.4 安装包
+11
View File
@@ -0,0 +1,11 @@
# v0.6.5 发布说明(2026-10-26)
## 修复
- **写操作不再因同步失败弹全屏报错**:此前任何一次写后同步失败(如服务端 500)都会以「应用内部错误」遮罩抛到界面,一次服务端故障会让每次点击都报错。现在写操作在本地提交成功即视为成功,同步失败仅记录状态(设置页「多端数据同步」可见原因),并由下一轮自动同步继续完成。
- **服务端 500 留痕**:接口异常现在会连同堆栈写入容器日志(`docker logs internlog-sync`),用于定位「同步服务返回 500」的真实原因。
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
- 桌面 / 安卓:安装 0.6.5(或继续用 0.6.2+ 旧版客户端,报错遮罩问题主要影响 0.6.4 网页端)
+23
View File
@@ -0,0 +1,23 @@
# v0.6.6 发布说明(2026-10-26)
## 修复「同步服务返回 500」与二次数据丢失(根因修复)
**根因**:此前数据恢复用的合并脚本把用户数据文件中的表写成了**数组**形态,而服务端按「uuid 键对象」读写。后果:数组上的附加属性在序列化时被丢弃(**每次容器重启丢失合并后新写的数据**)、删除/半途状态产生 null 行(**自然键扫描读到 null.date → 每次同步 500**)。该脚本缺陷由开发者引入,本版彻底治愈。
## 修复内容
- **服务端加载即自愈**:读取用户数据时自动把数组形态转回 uuid 键对象、剔除 null/非法行(一次性治愈线上文件,无需任何手动操作),自愈结果立即持久化
- **自然键扫描 / 墓碑生效循环**:对非对象行防御性跳过
- **客户端快照重建**:跳过 null / 无 uuid 行,垃圾行不再进入本地缓存
- 合并后丢失的增量数据:各端升级后首次同步会触发行数比对防线,**自动把设备缓存中的数据重推回服务器**
## 验证
- 表结构自愈冒烟 8/8(数组+null 混合文件 → 同步一次 → 治愈且数据完整、快照无 null)
- 存储冒烟 9/9 · 协议审计 36/36 · 用户管理审计 16/16 · 灾难恢复自救 7/7
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
- 桌面 / 安卓:安装 0.6.6(网页端刷新即新版)
- 部署后各端打开一次,核对数据(工作记录/记账等);若仍有缺,检查设置页「立即同步」的结果行并反馈
+27
View File
@@ -0,0 +1,27 @@
# v0.6.7 发布说明(2026-10-26)
## 全组件 UI 审核打磨(19 个界面/组件逐一过审)
**交互反馈**
- 按钮增加按下态(轻微下沉 + 主按钮阴影收起),主按钮带品牌色微阴影
- 全局细滚动条(8px 圆角,滚动区域观感统一)
- 修复侧栏 tooltip:仅折叠态(平板)显示,宽侧栏不再多余弹泡
**语义色与信息层级**
- 记账统计卡增加语义色条:收入绿 / 支出灰 / 结余蓝 / 待报销橙
- 长期计划进度条 100% 完成时变绿
- 危险确认弹窗标题红色警示
- 详情页出勤时间数字加大加粗;工作记录内容字重提升;总结页摘要数字加粗
- 密码库密码文本改等宽字体
- 记账表格金额列右对齐
**体验升级**
- 记录弹窗「耗时」新增快捷选择 chips(15/30/60/90/120 分)
- 登录页背景渐变质感、输入框加高
- 日历:今日格子淡蓝高亮、非本月格灰底、周标题字重
- 移动端「更多」弹层加拖拽指示条
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.7 安装包
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
@@ -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-CEj0vlG8.js","./index-o_Kd7ZbB.js","./index-CsewO6wh.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-o_Kd7ZbB.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-CEj0vlG8.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
+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-o_Kd7ZbB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CsewO6wh.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.6.7",
"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) })
+14 -3
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' })
@@ -74,7 +75,8 @@ async function main() {
check('LWW 反向: 旧 work_start 被墓碑', tombsOf(lwwR2).some(t => t.table === 'setting' && t.uuid === 'su1'))
await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'mt1', title: '待删', content: 'x', updated_at: '2026-04-01T00:00:00' }), row('memo', { uuid: 'ms2', title: '更迟', content: 'y', updated_at: '2030-01-01T00:00:00' })] } }, tokA)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: '2026-04-02T00:00:00' }] }, tokA)
// deleted_at 用当前时间:过期墓碑会被服务端 14 天 GC 自动回收(这是预期行为,不能用老日期验证回传)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: new Date().toISOString() }] }, tokA)
check('墓碑同步后行被清除', !((rowsOf(delPush).memo || []).some(x => x.uuid === 'mt1')))
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1'))
@@ -85,6 +87,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)
@@ -122,7 +128,12 @@ async function main() {
const aRec = regJson.users.find(u => u.username === 'audit_a')
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'))
// 当日首份快照不可变:registry.json 可能位于当日基准目录或其 _t 时间戳副本
const today = new Date().toISOString().slice(0, 10)
const todayDirs = fsMod.readdirSync('server/data/backups').filter(d => d.startsWith(today))
check('启动自动备份已生成', todayDirs.length > 0 && todayDirs.some(d => fsMod.existsSync('server/data/backups/' + d + '/registry.json')), todayDirs.join(','))
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)
+82
View File
@@ -0,0 +1,82 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
// node 环境的 localStorage 垫片(引擎的纪元/idMap 存取需要)
const lsMap = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (lsMap.has(k) ? lsMap.get(k)! : null),
setItem: (k: string, v: string) => { lsMap.set(k, String(v)) },
removeItem: (k: string) => { lsMap.delete(k) }
}
const BASE = 'http://127.0.0.1:3780'
let pass = 0
let fail = 0
function check(name: string, ok: boolean, detail?: string): void {
if (ok) { pass++; console.log(' PASS ' + name) }
else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function main() {
await fetch(BASE + '/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'restore', password: 'dev123456' })
}).catch(() => { /* 已存在忽略 */ })
const engine = await import('../src/sync/engine')
const CONF = { baseUrl: BASE, username: 'restore', password: 'dev123456' }
// ===== 设备 A:备忘录 x3 + 密码库密文 x1 + 设置 =====
bindDatabase(new WorklogDB('restore_a'))
const a = (await import('../src/db/schema')).db
const now = '2026-10-26T10:00:00'
await a.memo.bulkAdd([
{ title: '备忘一', content: '内容一', created_at: now, updated_at: now },
{ title: '备忘二', content: '内容二', created_at: now, updated_at: now },
{ title: '备忘三', content: '内容三', created_at: now, updated_at: now }
])
await a.credential.bulkAdd([
{ system: '企业邮箱', account: 'dev@x.com', password: 'enc:v1:FAKECIPHER', remark: '', created_at: now, updated_at: now }
])
await a.setting.bulkAdd([
{ key: 'work_start', value: '09:00' },
{ key: 'pwd_salt', value: 'SALTSALT' },
{ key: 'pwd_verifier', value: 'enc:v1:VERIFIER' }
])
const rA = await engine.runSync(CONF)
console.log('设备A 首次推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// ===== 灾难:服务器用户数据文件被删(模拟 rm -rf data / 目录重建)=====
const fsx = await import('fs')
const usersDir = 'server/data/users'
for (const f of fsx.readdirSync(usersDir)) fsx.rmSync(usersDir + '/' + f)
console.log('已删除服务器用户数据文件')
// ===== 设备 A 再次同步:应触发二道防线,自动全量重推自救 =====
// (设备 A 此前从未存过纪元 —— 恰好覆盖「首次升级即遇到灾难」的窗口)
const rA2 = await engine.runSync(CONF)
console.log('设备A 自救同步:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
check('自救时自动重推了全部本地数据(pushed>=7)', rA2.pushed >= 7, 'pushed=' + rA2.pushed)
const aMemo = await a.memo.toArray()
const aCred = await a.credential.toArray()
check('设备A 本地数据未被清空(备忘录仍 3 条)', aMemo.length === 3, 'len=' + aMemo.length)
check('设备A 本地密码库未被清空(仍 1 条)', aCred.length === 1, 'len=' + aCred.length)
// ===== 全新设备 C:拉取验证服务器已被 A 恢复 =====
bindDatabase(new WorklogDB('restore_c'))
const c = (await import('../src/db/schema')).db
const rC = await engine.runSync(CONF)
console.log('设备C 拉取:', JSON.stringify({ p: rC.pushed, e: rC.errors }))
const cMemo = await c.memo.toArray()
const cCred = await c.credential.toArray()
const cSet = await c.setting.toArray()
check('设备C 拉到 3 条备忘录', cMemo.length === 3, 'len=' + cMemo.length)
check('设备C 拉到密码库密文', cCred.length === 1 && cCred[0].system === '企业邮箱', 'len=' + cCred.length)
check('设备C 拉到主密码盐与校验值(密码库可解锁前提)', cSet.some(s => s.key === 'pwd_salt' && s.value === 'SALTSALT') && cSet.some(s => s.key === 'pwd_verifier'))
check('设备C 拉到考勤规则', cSet.some(s => s.key === 'work_start' && s.value === '09:00'))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail > 0) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+166 -92
View File
@@ -3,12 +3,14 @@ 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'
import MemoView from './views/MemoView.vue'
import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
import TimerPanel from './components/TimerPanel.vue'
import UserAdminView from './views/UserAdminView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
@@ -110,15 +112,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 +148,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) }
@@ -228,18 +231,31 @@ onMounted(async () => {
}
})
</script>
<template>
<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>
<div class="brand" @click="nav('calendar')">
<span class="brand-mark"><Icon name="calendar" :size="15" /></span>
<span class="brand-name">实习工作日志</span>
</div>
<div class="side-nav">
<template v-for="(v, i) in shownViews" :key="v.key">
<div v-if="i === 0 || v.group !== shownViews[i - 1].group" class="nav-group">{{ v.group }}</div>
<button class="nav-btn" :class="{ active: route === v.key }" :data-tip="v.name" @click="nav(v.key)">
<Icon :name="v.icon" :size="17" />
<span class="nav-label">{{ v.name }}</span>
</button>
</template>
</div>
<div class="side-foot">
<span class="avatar">{{ (session?.username || '用户').slice(0, 1).toUpperCase() }}</span>
<span class="foot-name">{{ session?.username }}</span>
<button class="icon-btn" title="退出登录" @click="doLogout">
<Icon name="export" :size="15" />
</button>
</div>
</nav>
<div class="main">
<header class="topbar">
@@ -277,9 +293,7 @@ onMounted(async () => {
<span class="dot" style="background: var(--warn)"></span>
填写本周周记
</button>
<span class="user-chip num mobile-hide">{{ session?.username }}</span>
<button class="btn btn-sm mobile-hide" @click="doLogout">退出</button>
<button class="btn btn-primary btn-sm" :disabled="clockDone" @click="doClock">
<button class="btn btn-primary" :disabled="clockDone" @click="doClock">
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
<span class="num">{{ clockLabel }}</span>
</button>
@@ -289,6 +303,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'" />
@@ -318,6 +333,7 @@ onMounted(async () => {
<!-- 手机端「更多」底部弹层 -->
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
<div class="more-sheet" @click.stop>
<div class="sheet-handle"></div>
<div class="sheet-user num">{{ session?.username }}</div>
<button v-for="v in moreViews" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
<Icon :name="v.icon" :size="18" />
@@ -365,6 +381,8 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- 全局计时时钟面板:有计划在计时时悬浮显示 -->
<TimerPanel v-if="session" />
<div class="toast-wrap">
<transition-group name="toast">
<div v-for="t in toasts" :key="t.id" class="toast">{{ t.text }}</div>
@@ -372,40 +390,74 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.app { display: flex; height: 100vh; height: 100dvh; overflow: hidden; }
.app { display: flex; height: 100vh; height: 100dvh; overflow: hidden; background: var(--bg-app); }
/* ---------- 侧栏 ---------- */
.side {
width: 56px;
width: var(--side-w);
flex: none;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10px;
gap: 4px;
background: var(--bg-page);
border-right: 1px solid var(--border);
}
.brand {
display: flex; align-items: center; gap: 9px;
height: var(--topbar-h); flex: none;
padding: 0 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.brand-mark {
width: 26px; height: 26px; flex: none;
border-radius: 7px;
background: var(--accent);
color: #FFFFFF;
display: flex; align-items: center; justify-content: center;
}
.brand-name { font-size: 14px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap; }
.side-nav {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-group {
font-size: 11px;
color: var(--text-3);
padding: 12px 8px 4px;
letter-spacing: 2px;
white-space: nowrap;
}
.nav-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-3);
display: flex; align-items: center; gap: 10px;
height: 36px; padding: 0 10px;
border: none; background: transparent;
border-radius: var(--radius-sm);
color: var(--text-2);
cursor: pointer;
border: none;
background: transparent;
font-size: 13px;
position: relative;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.nav-btn.active { background: var(--accent-bg); color: var(--accent); }
.nav-btn.active { background: var(--accent-bg); color: var(--accent); font-weight: 500; }
.nav-btn.active::before {
content: '';
position: absolute; left: 0; top: 8px; bottom: 8px;
width: 3px; border-radius: 2px;
background: var(--accent);
}
.nav-label { white-space: nowrap; }
/* tooltip 仅在折叠态(图标-only)显示;宽侧栏文字可见,不需要 */
.nav-btn::after {
content: attr(data-tip);
position: absolute;
left: 46px;
left: 50px;
top: 50%;
transform: translateY(-50%);
background: var(--text-1);
@@ -418,29 +470,57 @@ onMounted(async () => {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 40;
}
.side-foot {
flex: none;
display: flex; align-items: center; gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
}
.avatar {
width: 28px; height: 28px; flex: none;
border-radius: 50%;
background: var(--accent-bg);
color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600;
}
.foot-name {
flex: 1; min-width: 0;
font-size: 13px; color: var(--text-2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav-btn:hover::after { opacity: 1; }
/* ---------- 主区 ---------- */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
height: 56px;
height: var(--topbar-h);
flex: none;
background: var(--bg-page);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
z-index: 10;
}
.title { font-size: 14px; font-weight: 600; }
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
.gsearch { position: relative; width: 300px; max-width: 38vw; }
.gs-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
.gs-input { height: 30px; padding-left: 30px; }
.gsearch { position: relative; width: 320px; max-width: 38vw; }
.gs-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
.gs-input {
height: 32px; padding-left: 31px;
border-radius: 999px;
background: var(--bg-subtle);
border-color: transparent;
}
.gs-input:focus { background: var(--bg-page); border-color: var(--accent); }
.gs-panel {
position: absolute;
top: 36px;
top: 38px;
left: 0;
right: 0;
background: var(--bg-page);
@@ -457,7 +537,7 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
padding: 7px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
@@ -474,12 +554,6 @@ onMounted(async () => {
.gs-date { font-size: 12px; flex: none; }
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
.user-chip {
font-size: 12px;
color: var(--text-2);
padding: 0 4px;
}
.week-tip {
display: inline-flex;
align-items: center;
@@ -488,15 +562,16 @@ onMounted(async () => {
color: var(--warn);
background: var(--bg-page);
border-radius: 999px;
height: 26px;
height: 28px;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.week-tip:hover { background: var(--bg-subtle); }
.week-tip:hover { background: #FDF4EC; }
.content { flex: 1; overflow: auto; }
.content-inner { max-width: 1200px; margin: 0 auto; padding: 20px; }
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
.toast-wrap {
position: fixed;
@@ -521,11 +596,11 @@ onMounted(async () => {
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-4px); }
.toast-enter-active, .toast-leave-active { transition: all 0.15s ease; }
/* ---- 新增的手机端组件样式(桌面端隐藏) ---- */
/* ---------- 手机端组件 ---------- */
.mob-search-btn {
display: none;
width: 30px;
height: 30px;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
@@ -534,8 +609,6 @@ onMounted(async () => {
align-items: center;
justify-content: center;
}
.mobile-hide { display: initial; }
.tabbar {
display: none;
position: fixed;
@@ -549,7 +622,7 @@ onMounted(async () => {
}
.tab-btn {
flex: 1;
height: calc(52px - 6px);
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
@@ -561,13 +634,13 @@ onMounted(async () => {
cursor: pointer;
font-size: 11px;
}
.tab-btn.active { color: var(--accent); }
.tab-btn.active { color: var(--accent); font-weight: 500; }
.sheet-mask {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(29, 33, 41, 0.35);
background: rgba(29, 33, 41, 0.4);
}
.more-sheet {
position: absolute;
@@ -575,11 +648,19 @@ onMounted(async () => {
right: 0;
bottom: 0;
background: var(--bg-page);
border-radius: 14px 14px 0 0;
padding: 10px 14px calc(14px + env(safe-area-inset-bottom));
border-radius: 16px 16px 0 0;
padding: 6px 14px calc(14px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--border-strong);
margin: 4px auto 8px;
flex: none;
}
.sheet-user {
font-size: 12px;
color: var(--text-3);
@@ -607,17 +688,8 @@ onMounted(async () => {
.sheet-divider { height: 1px; background: var(--border); margin: 6px 0; }
.sheet-danger { color: var(--danger); }
.msw-mask {
position: fixed;
inset: 0;
z-index: 70;
background: var(--bg-page);
}
.msw {
height: 100%;
display: flex;
flex-direction: column;
}
.msw-mask { position: fixed; inset: 0; z-index: 70; background: var(--bg-page); }
.msw { height: 100%; display: flex; flex-direction: column; }
.msw-bar {
height: 52px;
flex: none;
@@ -652,8 +724,20 @@ onMounted(async () => {
}
.msw-item:hover, .msw-item:active { background: var(--bg-hover); }
/* ---------- 平板(721-1000px):侧栏折叠为图标 ---------- */
@media (max-width: 1000px) and (min-width: 721px) {
.side { width: var(--side-w-collapsed); }
.brand { justify-content: center; padding: 0; }
.brand-name, .nav-label, .nav-group { display: none; }
.nav-btn { justify-content: center; padding: 0; width: 40px; align-self: center; }
.nav-btn.active::before { display: none; }
.nav-btn:hover::after { opacity: 1; }
.side-foot { flex-direction: column; gap: 6px; }
.foot-name { display: none; }
}
/* ---------- 手机端(<=720px) ---------- */
@media (max-width: 720px) {
/* 布局骨架:顶栏一行 + 内容 + 底部标签栏 */
.app { display: block; height: 100dvh; }
.side { display: none; }
.main { display: flex; flex-direction: column; height: 100dvh; min-width: 0; }
@@ -669,29 +753,19 @@ onMounted(async () => {
.gsearch .gs-panel { display: none; }
.mob-search-btn { display: flex; }
.topbar-actions { margin-left: auto; gap: 6px; }
.mobile-hide { display: none !important; }
.week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; }
.topbar-actions .btn { padding: 0 10px; }
.topbar-actions .btn { padding: 0 12px; height: 34px; }
.topbar-actions .btn .num { font-size: 12px; }
.tabbar { display: flex; }
.tab-btn { height: 56px; font-size: 11px; }
.tab-btn .icon { width: 22px; height: 22px; }
.content { flex: 1; min-height: 0; }
.content-inner {
max-width: none;
padding: 12px 12px calc(66px + env(safe-area-inset-bottom));
padding: 12px 12px calc(78px + env(safe-area-inset-bottom));
}
/* 卡片与表单:统一左右贴边、内距收紧 */
.card { border-radius: var(--radius-md); }
input.input, select.select, textarea.textarea { width: 100%; box-sizing: border-box; }
.btn { touch-action: manipulation; }
/* 打卡按钮更醒目 */
.topbar-actions .btn-primary {
height: 32px;
}
/* 提示信息对齐左上,避免遮挡底部标签栏 */
.toast-wrap {
top: 6px;
right: 10px;
+4 -25
View File
@@ -28,7 +28,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-title">{{ title }}</div>
<div class="dialog-title" :class="{ 'is-danger': danger }">{{ title }}</div>
<div class="dialog-msg">{{ message }}</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button>
@@ -41,29 +41,8 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
width: 380px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
padding: 20px;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog { width: 380px; }
.dialog-title.is-danger { color: var(--danger); }
.dialog-msg { margin-top: 8px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
.dialog-btns { margin-top: 20px; display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
.btn-danger-solid:hover { background: #D13A3F; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.dialog-btns { margin-top: 20px; }
</style>
+1 -38
View File
@@ -112,44 +112,7 @@ function save() {
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 480px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.dialog { width: 480px; }
.pwd-row { display: flex; gap: 8px; }
.pwd-row .input { flex: 1; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { listRunningPlan, stopPlanTimer, type PlanItem } from '../db/repo'
import { planTimerPulse } from '../lib/bus'
import { showToast } from '../lib/toast'
// 全局计时时钟面板:任何页面都能看到正在计时的计划,实时走秒、一键停止、点击跳转计划页
const plan = ref<PlanItem | null>(null)
const nowMs = ref(Date.now())
let poll: number | null = null
let tick: number | null = null
async function refresh() {
try {
plan.value = await listRunningPlan()
} catch { /* 库未就绪时静默,下轮轮询重试 */ }
}
const elapsedSec = computed(() => {
const p = plan.value
if (!p || !p.timer_started_at) return 0
const base = (p.actual_minutes || 0) * 60
const extra = Math.max(0, Math.floor((nowMs.value - new Date(p.timer_started_at).getTime()) / 1000))
return base + extra
})
const elapsedText = computed(() => {
const s = elapsedSec.value
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const ss = s % 60
const p2 = (n: number) => String(n).padStart(2, '0')
return h > 0 ? h + ':' + p2(m) + ':' + p2(ss) : m + ':' + p2(ss)
})
async function stop() {
const p = plan.value
if (!p) return
await stopPlanTimer(p.id as number)
showToast('已停止计时:' + p.title)
await refresh()
}
function goPlan() {
location.hash = '#/plan'
}
watch(planTimerPulse, () => { void refresh() })
onMounted(() => {
void refresh()
poll = window.setInterval(() => { void refresh() }, 15000)
tick = window.setInterval(() => { nowMs.value = Date.now() }, 1000)
window.addEventListener('focus', refresh)
})
onUnmounted(() => {
if (poll !== null) window.clearInterval(poll)
if (tick !== null) window.clearInterval(tick)
window.removeEventListener('focus', refresh)
})
</script>
<template>
<transition name="tp">
<div v-if="plan && plan.timer_started_at" class="tp" @click="goPlan" title="点击查看计划页">
<span class="tp-pulse"></span>
<span class="tp-main">
<span class="tp-title">{{ plan.title }}</span>
<span class="tp-meta">{{ plan.kind }} · 计时中</span>
</span>
<span class="tp-time num">{{ elapsedText }}</span>
<span class="tp-stop" role="button" title="停止计时" @click.stop="stop">停止</span>
</div>
</transition>
</template>
<style scoped>
.tp {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 40;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
max-width: 340px;
padding: 10px 12px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-float);
cursor: pointer;
text-align: left;
transition: border-color 0.12s;
}
.tp:hover { border-color: var(--border-strong); }
.tp-pulse {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
position: relative;
flex: none;
}
.tp-pulse::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--accent);
animation: tp-ring 1.6s ease-out infinite;
}
@keyframes tp-ring {
0% { transform: scale(0.5); opacity: 0.6; }
100% { transform: scale(1.5); opacity: 0; }
}
.tp-main { display: flex; flex-direction: column; min-width: 0; }
.tp-title {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tp-meta { font-size: 11px; color: var(--text-3); }
.tp-time {
font-size: 16px;
font-weight: 600;
color: var(--accent);
font-variant-numeric: tabular-nums;
margin-left: 2px;
flex: none;
}
.tp-stop {
flex: none;
font-size: 12px;
color: var(--text-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 3px 10px;
transition: all 0.12s;
}
.tp-stop:hover { color: var(--danger); border-color: var(--danger); background: #FDF0F0; }
.tp-enter-active, .tp-leave-active { transition: all 0.2s ease; }
.tp-enter-from, .tp-leave-to { opacity: 0; transform: translateY(10px); }
@media (max-width: 720px) {
.tp {
left: 12px;
right: 12px;
bottom: calc(64px + env(safe-area-inset-bottom));
max-width: none;
}
}
</style>
+32 -56
View File
@@ -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
@@ -47,11 +48,26 @@ watch(() => props.open, async v => {
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
watch(fType, t => {
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
// 切换收支类型后:重新拉取对应分类列表,并回落到第一项
watch(fType, async () => {
await loadCats()
fCategory.value = categoryOptions.value[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 +125,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>
@@ -140,55 +160,11 @@ function save() {
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
.dialog { width: 480px; }
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
@media (max-width: 720px) {
.add-cat-input { height: 34px; }
.add-cat .btn { height: 34px; }
}
.dialog {
width: 480px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn {
height: 30px;
padding: 0 14px;
border: none;
background: var(--bg-page);
color: var(--text-2);
cursor: pointer;
font-size: 13px;
}
.seg-btn + .seg-btn { border-left: 1px solid var(--border); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.input.invalid, .input.invalid:focus { border-color: var(--danger); }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
+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"/>')
}
+56 -131
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,60 +161,18 @@ 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
}
// ---- 条目操作 ----
// 弹窗开关与编辑目标分离:添加模式 editTarget 为 null,但弹窗也要打开
const editTarget = ref<LogItem | null>(null)
const dialogOpen = ref(false)
function openEdit(it: LogItem) {
editTarget.value = it
dialogOpen.value = true
}
function openAdd() {
editTarget.value = null
dialogOpen.value = true
}
async function onEditSave(payload: {
id: number
@@ -224,21 +185,23 @@ 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
dialogOpen.value = false
await load()
showToast('修改已保存')
}
async function toggleMilestone(it: LogItem) {
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
@@ -378,54 +341,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">
@@ -483,11 +403,11 @@ async function confirmDelete() {
/>
<EditLogDialog
:open="!!editTarget"
:open="dialogOpen"
:item="editTarget"
:projects="projectsAll"
:date="date"
@cancel="editTarget = null"
@cancel="dialogOpen = false"
@save="onEditSave"
/>
</div>
@@ -497,46 +417,51 @@ async function confirmDelete() {
.dp-head { margin-bottom: 12px; }
.dp-date { font-size: 14px; font-weight: 600; }
.sec { padding: 14px 16px; margin-bottom: 12px; }
.sec-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-1); }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.form-hint { font-size: 12px; }
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.att-cell label, .field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.att-cell label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.att-val { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 13px; }
.status-val { min-height: 32px; }
.att-val > .num { font-size: 14px; font-weight: 600; letter-spacing: 0.2px; }
.val-empty { color: var(--text-3); }
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.time-input { width: 104px; }
.att-note { margin-top: 10px; font-size: 12px; }
.att-actions { display: flex; gap: 8px; margin-top: 12px; }
.att-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.form-footer {
display: flex; justify-content: flex-end; gap: 8px;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
}
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid { border-color: var(--danger); }
.input.invalid:focus { border-color: var(--danger); }
.list-sec { margin-bottom: 4px; }
.item { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 6px; background: var(--bg-page); }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.item {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 9px 12px;
margin-bottom: 6px;
background: var(--bg-subtle);
transition: border-color 0.12s, background 0.12s;
}
.item:hover { border-color: var(--border-strong); background: var(--bg-page); }
.item-main { display: flex; align-items: center; gap: 8px; min-height: 26px; flex-wrap: wrap; }
.item-content { cursor: text; word-break: break-all; }
.item-content { cursor: text; word-break: break-all; font-weight: 500; }
.item-meta { font-size: 12px; }
.item-sub {
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
font-size: 12px; display: flex; flex-direction: column; gap: 2px;
}
.empty { text-align: center; padding: 24px 0; }
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) {
.att-grid, .form-grid { grid-template-columns: 1fr; }
.field.span2 { grid-column: auto; }
@media (max-width: 720px) {
.dp-head { margin-bottom: 10px; }
.att-grid { grid-template-columns: 1fr 1fr; gap: 10px 12px; }
.att-cell label { margin-bottom: 3px; }
.att-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.att-actions .btn { width: 100%; }
.edit-row { flex-wrap: wrap; }
.edit-row .time-input { width: 100%; flex: 1 1 100%; }
.edit-row .btn { flex: 1; }
.item-main { gap: 6px; }
}
</style>
+26 -62
View File
@@ -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', {
@@ -110,7 +113,7 @@ function save() {
<div class="dialog" role="dialog" aria-label="编辑工作记录">
<div class="dialog-head">
<div>
<div class="dialog-title">编辑工作记录</div>
<div class="dialog-title">{{ item ? '编辑工作记录' : '添加工作记录' }}</div>
<div v-if="date" class="muted dialog-sub">{{ date }}</div>
</div>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
@@ -137,12 +140,19 @@ 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">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="formMinutes" placeholder="选填" />
<div class="min-chips">
<button
v-for="m in [15, 30, 60, 90, 120]" :key="m" type="button"
class="min-chip" :class="{ on: formMinutes === m }"
@click="formMinutes = formMinutes === m ? null : m"
>{{ m }}分</button>
</div>
</div>
<div class="field">
<label>主要收获</label>
@@ -170,7 +180,7 @@ function save() {
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存修改</button>
<button class="btn btn-primary btn-sm" @click="save">{{ item ? '保存修改' : '添加' }}</button>
</div>
</div>
</div>
@@ -179,65 +189,19 @@ function save() {
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 560px;
max-width: calc(100vw - 32px);
max-height: 85vh;
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
display: flex;
flex-direction: column;
}
.dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex: none;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-sub { font-size: 12px; margin-top: 2px; }
.dialog-body { padding: 16px 20px; overflow: auto; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
flex: none;
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid, .input.invalid:focus { border-color: var(--danger); }
.toggle-btn {
height: 32px;
width: 100%;
.dialog { width: 560px; }
.min-chips { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.min-chip {
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
border-radius: 999px;
height: 24px;
padding: 0 10px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
transition: all 0.12s;
}
.toggle-btn:hover { background: var(--bg-hover); }
.toggle-btn.on { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.toggle-btn.onwarn { color: var(--warn); border-color: var(--warn); }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.min-chip:hover { background: var(--bg-hover); }
.min-chip.on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
</style>
+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))
}
+203 -6
View File
@@ -1,7 +1,10 @@
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 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'
// 视图直接从 repo 导入这些类型:在此统一再导出
export type { Attendance, Credential, LogItem, Memo, Goal, PlanItem, Project, Transaction, WeeklyNote, WorkLog }
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
import { flushAfterWrite } from '../sync/engine'
@@ -360,6 +363,192 @@ 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 listRunningPlan(): Promise<PlanItem | null> {
const rows = await db.plan.filter(p => !!p.timer_started_at).toArray()
return rows.length ? rows[0] : null
}
export async function addPlan(input: PlanInput): Promise<void> {
const now = nowLocalISO()
await db.plan.add({
date: input.date,
start_time: input.start_time,
end_time: input.end_time,
title: input.title.trim(),
kind: input.kind.trim() || '其他',
note: input.note || null,
status: 'todo',
actual_minutes: 0,
timer_started_at: null,
uuid: '',
created_at: now,
updated_at: now
} as never)
await commit()
}
export async function updatePlan(id: number, patch: Partial<PlanItem>): Promise<void> {
await db.plan.update(id, { ...patch, updated_at: nowLocalISO() })
await commit()
}
export async function deletePlan(id: number): Promise<void> {
await db.plan.delete(id)
await commit()
}
// 打卡:完成 / 跳过 / 恢复
export async function setPlanStatus(id: number, status: 'todo' | 'done' | 'skip'): Promise<void> {
const row = await db.plan.get(id)
if (!row) return
if (status !== 'todo' && row.timer_started_at) {
await stopPlanTimer(id)
}
await db.plan.update(id, { status, updated_at: nowLocalISO() })
await commit()
}
// 计时时钟:同一时刻只允许一个条目在计时,开始新计时会自动停止上一个
export async function startPlanTimer(id: number): Promise<void> {
const running = await db.plan.filter(p => !!p.timer_started_at).toArray()
for (const p of running) {
if (p.id !== id) await stopPlanTimer(p.id as number)
}
await db.plan.update(id, { timer_started_at: nowLocalISO(), updated_at: nowLocalISO() })
await commit()
}
export async function stopPlanTimer(id: number): Promise<void> {
const row = await db.plan.get(id)
if (!row || !row.timer_started_at) return
const elapsed = Math.max(0, Math.round((Date.now() - new Date(row.timer_started_at).getTime()) / 60000))
await db.plan.update(id, {
actual_minutes: (row.actual_minutes || 0) + elapsed,
timer_started_at: null,
updated_at: nowLocalISO()
})
await commit()
}
// ---------- 长期计划(目标) ----------
export interface GoalInput {
title: string
kind: string
start_date: string
end_date: string
note: string
}
const MAX_MATERIALIZE_DAYS = 366
function eachDate(start: string, end: string): string[] {
const out: string[] = []
let cur = start
let guard = 0
while (cur <= end && guard < MAX_MATERIALIZE_DAYS) {
out.push(cur)
cur = addDays(cur, 1)
guard++
}
return out
}
export async function getGoalProgress(): Promise<{ goalId: number; done: number; total: number }[]> {
const goals = await db.goal.toArray()
const out: { goalId: number; done: number; total: number }[] = []
for (const g of goals) {
const items = await db.plan.where('goal_id').equals(g.id as number).toArray()
out.push({ goalId: g.id as number, done: items.filter(i => i.status === 'done').length, total: items.length })
}
return out
}
export async function listGoals(): Promise<Goal[]> {
const rows = await db.goal.toArray()
return rows.sort((a, b) => (a.start_date < b.start_date ? -1 : 1))
}
// 新建长期计划并物化每一天的任务(落实每日打卡与计时)
export async function addGoal(input: GoalInput): Promise<void> {
const now = nowLocalISO()
const gid = await db.goal.add({
title: input.title.trim(),
kind: input.kind.trim() || '其他',
start_date: input.start_date,
end_date: input.end_date,
note: input.note || null,
uuid: '',
created_at: now,
updated_at: now
} as never)
const goal = await db.goal.get(gid)
const dates = eachDate(input.start_date, input.end_date)
for (const d of dates) {
await db.plan.add({
date: d,
start_time: '08:00',
end_time: '09:00',
title: input.title.trim(),
kind: input.kind.trim() || '其他',
note: input.note || null,
status: 'todo',
actual_minutes: 0,
timer_started_at: null,
goal_id: gid,
uuid: '',
created_at: now,
updated_at: now
} as never)
}
void goal
await commit()
}
// 编辑目标:标题/类别级联到其每日任务(已完成项不改动)
export async function updateGoal(id: number, patch: Partial<GoalInput>): Promise<void> {
const goal = await db.goal.get(id)
if (!goal) return
const next = { ...patch, updated_at: nowLocalISO() }
await db.goal.update(id, next)
const items = await db.plan.where('goal_id').equals(id).toArray()
for (const it of items) {
if (it.status === 'done') continue
await db.plan.update(it.id as number, {
...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
...(patch.kind !== undefined ? { kind: patch.kind } : {}),
...(patch.note !== undefined ? { note: patch.note || null } : {}),
updated_at: nowLocalISO()
})
}
await commit()
}
// 删除目标:连同其每日任务一起删除(前端二次确认)
export async function deleteGoal(id: number): Promise<void> {
const items = await db.plan.where('goal_id').equals(id).toArray()
for (const it of items) await db.plan.delete(it.id as number)
await db.goal.delete(id)
await commit()
}
// ---------- 备忘录 ----------
export async function listMemos(): Promise<Memo[]> {
@@ -457,7 +646,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 +662,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 +676,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 +699,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)
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+6
View File
@@ -9,3 +9,9 @@ export const attendanceChanged = ref(0)
export function notifyAttendanceChanged(): void {
attendanceChanged.value++
}
// 计划计时启停信号:全局计时时钟面板监听并即时刷新
export const planTimerPulse = ref(0)
export function notifyPlanTimer(): void {
planTimerPulse.value++
}
+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 { /* 浮层失败时保持静默 */ }
}
+48 -26
View File
@@ -1,11 +1,40 @@
/* ============================================================
手机端(<=720px)全局适配:作用于各视图与弹窗组件
(App.vue 的壳层样式是 scoped,跨组件规则统一放这里,便于集中管理)
手机端(<=720px)全局适配
原则:触控目标 ≥30px(按钮 32-36 / 输入 38 / 弹窗主按钮 40)、
板块两列优先避免过度堆叠、内容优先密度适中
============================================================ */
/* 通用的白底卡片在窄屏贴边 */
@media (max-width: 720px) {
/* ---------- 触控基线 ---------- */
.btn { height: 36px; padding: 0 14px; }
.btn-sm { height: 32px; padding: 0 12px; }
.btn-xs { height: 30px; padding: 0 12px; }
.icon-btn { width: 34px; height: 34px; }
.seg-btn { height: 34px; padding: 0 13px; }
.input, .select { height: 38px; padding: 0 12px; }
.textarea { min-height: 76px; padding: 10px 12px; }
.toggle-btn { height: 38px; }
.tag { height: 24px; line-height: 22px; padding: 0 9px; }
.dot { width: 7px; height: 7px; }
/* ---------- 弹窗:近全屏 + 大按钮 ---------- */
.dialog {
width: calc(100vw - 16px) !important;
max-width: calc(100vw - 16px) !important;
max-height: 90vh;
max-height: 90dvh;
}
.dialog-head { padding: 14px 16px 10px !important; }
.dialog-body { padding: 14px 16px !important; }
.dialog-btns { padding: 10px 16px calc(12px + env(safe-area-inset-bottom)) !important; gap: 10px; }
.dialog-btns .btn { flex: 1; height: 40px; }
.form-grid { grid-template-columns: 1fr !important; }
/* ---------- 卡片与分区 ---------- */
.card { border-radius: 12px; }
.section, .sec { padding: 12px; }
.stat-num { font-size: 20px; }
.empty { padding: 22px 12px; }
input.input, select.select, textarea.textarea {
width: 100%;
@@ -13,20 +42,7 @@
}
.btn { touch-action: manipulation; }
/* ---------- 弹窗:窄屏一体布局,覆盖组件内固定宽度 ---------- */
.dialog {
width: calc(100vw - 20px) !important;
max-width: calc(100vw - 20px) !important;
max-height: 86vh;
max-height: 86dvh;
overflow: auto;
}
.dialog-body { padding: 14px !important; }
.form-grid { grid-template-columns: 1fr !important; }
.dialog-btns { gap: 8px; }
.dialog-btns .btn { flex: 1; }
/* ---------- 日历当日抽屉:改为底部抽屉 ---------- */
/* ---------- 日历当日抽屉:底部抽屉 ---------- */
.drawer {
top: auto !important;
left: 0 !important;
@@ -35,13 +51,14 @@
border-left: none !important;
border-top: 1px solid var(--border);
border-radius: 14px 14px 0 0 !important;
box-shadow: 0 -8px 24px rgba(16, 24, 40, 0.10);
}
.drawer-enter-active, .drawer-leave-active { transition: transform 0.2s ease, opacity 0.2s ease; }
.drawer-enter-from, .drawer-leave-to { transform: translateY(28px); opacity: 0; }
.drawer-head { border-radius: 14px 14px 0 0; }
.drawer-body { padding: 12px !important; }
/* ---------- 记账表格:卡片行(带行内标签),手机上不横滚 ---------- */
/* ---------- 记账表格:卡片行(带行内标签) ---------- */
.tx-table { min-width: 0 !important; }
.tx-table thead { display: none !important; }
.tx-table tr { display: block; padding: 10px 12px; border-bottom: 1px solid var(--border); background: transparent; }
@@ -62,11 +79,12 @@
flex: none;
}
.tx-table td.ops-cell::before { content: none; }
.tx-table td.ops-cell { justify-content: flex-end; }
.tx-table td.ops-cell { justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
.tx-table td.ops-cell .btn { flex: none; }
.tx-table td.empty { justify-content: center; }
.tx-table td.empty::before { content: none !important; }
/* ---------- 密码库等其余宽表:横向滚动,不再挤压折行 ---------- */
/* ---------- 密码库等宽表:横滚 + 工具栏堆叠 ---------- */
.table-card, .tx-card { overflow-x: auto; }
.cred-table { min-width: 560px !important; }
.th-ops { width: auto !important; }
@@ -75,12 +93,16 @@
.vault-toolbar .flex1 { display: none; }
.vault-toolbar .btn { flex: none; }
/* ---------- 日历格子收窄 ---------- */
.cal-toolbar { margin-bottom: 8px; }
.cal-cell { min-height: 54px !important; padding: 4px 5px !important; }
.cal-marks { margin-top: 3px !important; height: 12px !important; gap: 3px !important; }
/* ---------- 统计卡两列 ---------- */
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 10px !important; }
.stat-card { padding: 12px 14px !important; }
/* ---------- 各种桌面习惯型小间距收敛 ---------- */
.section, .sec { padding: 12px 12px !important; }
/* ---------- 分区与栅格 ---------- */
.rule-grid { grid-template-columns: 1fr !important; }
.two-col { grid-template-columns: 1fr !important; }
/* ---------- 日历格子 ---------- */
.cal-toolbar { margin-bottom: 10px; }
.cal-cell { min-height: 58px !important; padding: 5px 6px !important; }
.cal-marks { margin-top: 4px !important; height: 14px !important; gap: 4px !important; }
}
+222 -43
View File
@@ -1,50 +1,72 @@
/* ============================================================
设计基座 ·「高级白」企业级
层次模型:内容区浅灰底(--bg-app) → 白色卡片(--bg-page) →
浅灰内嵌块(--bg-subtle)。所有视图共用此处组件类,禁止各自造轮子。
============================================================ */
:root {
--bg-page: #FFFFFF;
--bg-subtle: #F7F8FA;
/* 表面 */
--bg-app: #F5F6F8; /* 应用内容区底色 */
--bg-page: #FFFFFF; /* 卡片 / 面板 */
--bg-subtle: #F7F8FA; /* 表头 / 内嵌块 */
--bg-hover: #F2F3F5;
--border: #EBEDF0;
--border-strong: #DCDEE3;
/* 文字 */
--text-1: #1D2129;
--text-2: #4E5969;
--text-3: #86909C;
/* 线 */
--border: #E8EAEE;
--border-strong: #DCDFE4;
/* 品牌与语义色 */
--accent: #3370FF;
--accent-bg: #F0F5FF;
--accent-deep: #2B5FD9;
--accent-bg: #EEF4FF;
--ok: #23A757;
--warn: #E8842C;
--danger: #E5484D;
--dot-empty: #C9CDD4;
/* 几何 */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-float: 0 4px 16px rgba(29, 33, 41, 0.08);
--radius-md: 10px;
--radius-lg: 14px;
/* 阴影 */
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04);
--shadow-float: 0 12px 32px -8px rgba(16, 24, 40, 0.14), 0 4px 12px rgba(16, 24, 40, 0.06);
/* 布局 */
--side-w: 216px;
--side-w-collapsed: 60px;
--topbar-h: 56px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-page);
background: var(--bg-app);
color: var(--text-1);
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button, input, select, textarea { font: inherit; color: inherit; }
/* ---------- 通用工具类 ---------- */
.num { font-variant-numeric: tabular-nums; }
.flex1 { flex: 1; }
.flex1 { flex: 1; min-width: 0; }
.muted { color: var(--text-3); }
.muted2 { color: var(--text-2); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* ---------- 按钮 ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
padding: 0 14px;
height: 32px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
@@ -52,15 +74,41 @@ button, input, select, textarea { font: inherit; color: inherit; }
color: var(--text-1);
cursor: pointer;
white-space: nowrap;
font-weight: 500;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover { background: var(--bg-hover); }
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; }
.btn-primary:hover { background: #2B5FD9; }
.btn-danger { color: var(--danger); border-color: var(--danger); }
.btn-sm { height: 26px; padding: 0 10px; font-size: 12px; }
.btn-xs { height: 22px; padding: 0 8px; font-size: 12px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; box-shadow: 0 1px 2px rgba(51, 112, 255, 0.28); }
.btn-primary:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
.btn-primary:active { box-shadow: none; }
.btn-danger { color: var(--danger); border-color: var(--danger); background: var(--bg-page); }
.btn-danger:hover { background: #FDF0F0; }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
.btn-danger-solid:hover { background: #D13A3F; }
.btn-sm { height: 28px; padding: 0 12px; font-size: 12px; }
.btn-xs { height: 24px; padding: 0 10px; font-size: 12px; }
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: none;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--text-3);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.icon-btn.active { color: var(--accent); }
.icon-btn.active-warn { color: var(--warn); }
/* ---------- 表单 ---------- */
.input, .select, .textarea {
height: 32px;
padding: 0 10px;
@@ -69,30 +117,30 @@ button, input, select, textarea { font: inherit; color: inherit; }
background: var(--bg-page);
outline: none;
width: 100%;
transition: border-color 0.12s, box-shadow 0.12s;
}
.textarea { height: auto; min-height: 64px; padding: 8px 10px; resize: vertical; line-height: 1.7; }
.input:focus, .select:focus, .textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(51, 112, 255, 0.10);
}
.textarea { height: auto; min-height: 56px; padding: 8px 10px; resize: vertical; }
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
.input::placeholder, .textarea::placeholder { color: var(--text-3); }
.input.invalid, .input.invalid:focus { border-color: var(--danger); box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.10); }
.card { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-page); }
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex: none;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--text-3);
cursor: pointer;
/* ---------- 卡片与分区 ---------- */
.card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-page);
box-shadow: var(--shadow-card);
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.icon-btn.active { color: var(--accent); }
.icon-btn.active-warn { color: var(--warn); }
.section { padding: 16px 18px; }
.section-title, .sec-title { font-weight: 600; margin-bottom: 12px; font-size: 13px; color: var(--text-1); }
.sec { padding: 16px 18px; }
.empty { text-align: center; padding: 28px 0; }
/* ---------- 标签与状态点 ---------- */
.tag {
display: inline-block;
padding: 0 8px;
@@ -105,19 +153,150 @@ button, input, select, textarea { font: inherit; color: inherit; }
border: 1px solid var(--border);
white-space: nowrap;
}
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex: none; }
.dot.normal { background: var(--ok); }
.dot.late, .dot.early, .dot.incomplete { background: var(--warn); }
.dot.absent { background: var(--danger); }
.dot.none, .dot.leave { background: var(--dot-empty); }
.dot.rest { background: var(--border); }
/* 移动端:避免 iOS 聚焦缩放,输入字号提到 16px */
/* ---------- 金额 ---------- */
.amt-income { color: var(--ok); }
.amt-expense { color: var(--text-1); }
.amt-warn { color: var(--warn); }
/* ---------- 分段选择器(seg) ---------- */
.seg {
display: inline-flex;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-page);
}
.seg-btn {
border: none;
background: var(--bg-page);
color: var(--text-2);
cursor: pointer;
font-size: 12px;
height: 28px;
padding: 0 12px;
border-right: 1px solid var(--border);
transition: background 0.12s, color 0.12s;
}
.seg-btn:last-child { border-right: none; }
.seg-btn:hover { background: var(--bg-hover); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); font-weight: 500; }
/* ---------- 开关按钮(toggle) ---------- */
.toggle-btn {
height: 32px;
width: 100%;
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-2);
transition: all 0.12s;
}
.toggle-btn:hover { background: var(--bg-hover); }
.toggle-btn.on { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.toggle-btn.onwarn { color: var(--warn); border-color: var(--warn); background: #FDF4EC; }
/* ---------- 数据表格(记账/密码库/用户管理共用) ---------- */
.tx-table, .cred-table, .admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tx-table th, .cred-table th, .admin-table th {
text-align: left;
font-weight: 500;
color: var(--text-3);
font-size: 12px;
background: var(--bg-subtle);
padding: 9px 14px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.tx-table td, .cred-table td, .admin-table td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
word-break: break-all;
}
.tx-table tbody tr:hover, .cred-table tbody tr:hover, .admin-table tbody tr:hover { background: #FAFBFC; }
.tx-table tbody tr:last-child td, .cred-table tbody tr:last-child td, .admin-table tbody tr:last-child td { border-bottom: none; }
/* ---------- 弹窗(全部对话框共用) ---------- */
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
padding: 16px;
}
.dialog {
width: 480px;
max-width: calc(100vw - 32px);
max-height: 86vh;
max-height: 86dvh;
overflow: auto;
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
display: flex;
flex-direction: column;
}
.dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex: none;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-sub { font-size: 12px; margin-top: 2px; color: var(--text-3); }
.dialog-body { padding: 16px 20px; overflow: auto; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
flex: none;
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.form-grid .field.span2, .field.span2 { grid-column: span 2; }
.field label, .form-row label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
/* ---------- 统计卡与横向条 ---------- */
.stat-card { padding: 14px 16px; }
.stat-num { font-size: 22px; font-weight: 600; margin-top: 2px; letter-spacing: -0.2px; }
.bar-row { display: flex; align-items: center; gap: 10px; margin: 7px 0; }
.bar-label { flex: none; color: var(--text-2); font-size: 12px; text-align: right; }
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.bar-num { font-size: 12px; flex: none; color: var(--text-3); }
/* ---------- 滚动条(桌面细滚动条,移动端系统默认) ---------- */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #D3D7DE; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #B9BEC7; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-corner { background: transparent; }
/* 记账表格:金额列右对齐(数字位对齐更利扫读) */
.tx-table th:nth-child(3), .tx-table td:nth-child(3) { text-align: right; }
/* ---------- 图标 ---------- */
.icon { display: inline-flex; align-items: center; justify-content: center; flex: none; }
.icon svg { width: 100%; height: 100%; display: block; }
/* ---------- 移动端:避免 iOS 聚焦缩放 ---------- */
@media (max-width: 720px) {
input, select, textarea { font-size: 16px !important; }
input[type="checkbox"] { font-size: initial; }
}
.dot.rest { background: var(--border); }
.icon { display: inline-flex; align-items: center; justify-content: center; flex: none; }
.icon svg { width: 100%; height: 100%; display: block; }
+51 -6
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'])
@@ -159,14 +159,15 @@ export async function syncWithStoredConfig(): Promise<SyncResult> {
return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password })
}
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存(失败会把缓存回退到服务器真值)
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存
// 注意:绝不向界面抛出——写操作本身已在本地提交成功,同步失败只记录状态
// (设置页可见 + 下一轮自动同步续传),否则一次服务端故障会把每次点击都变成全屏报错
export async function flushAfterWrite(): Promise<void> {
try {
await syncWithStoredConfig()
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ }
throw e
}
}
@@ -175,6 +176,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 +193,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]))
}
}
@@ -292,7 +301,7 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
}
}
async function doRunSync(config: SyncConfig): Promise<SyncResult> {
async function doRunSync(config: SyncConfig, epochRetry = false): Promise<SyncResult> {
const base = config.baseUrl.trim().replace(/\/+$/, '')
if (!base) throw new Error('请先配置同步服务地址')
await ensureSyncFields()
@@ -350,9 +359,31 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
}
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
const data = await resp.json()
// 服务器数据纪元检测:纪元变化说明服务端数据存储被重建(如误删 data 目录后重启)。
// 此时本地镜像不可信(镜像认为「早已同步」而不推送,随后会拉到空快照把本地缓存清空)。
// 处置:作废镜像并以本地全量数据重推一次,让服务器从本设备恢复。
const epoch = String(data.dataEpoch || '')
const epochKey = 'dsh_sync_epoch_' + (getCloudCreds()?.username || 'anon')
const prevEpoch = epoch ? localStorage.getItem(epochKey) : null
if (!epochRetry && prevEpoch && epoch && prevEpoch !== epoch) {
await db.sync_mirror.clear()
localStorage.setItem(epochKey, epoch)
return doRunSync(config, true)
}
const serverTables: Record<string, AnyRow[]> = data.tables || {}
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
// 二道防线(不依赖纪元存储,首次升级即可生效):
// 本地镜像认为服务器应有 N 行,但服务器实际行数更少 —— 说明服务器数据发生过丢失/重建。
// 此时若照常拉快照,会把本地缓存也清空。处置:作废镜像并以本地全量数据重推自救。
const serverCount = TABLES.reduce((s, tn) => s + ((serverTables[tn] || []).length), 0)
const expectedMin = mirrorRows.length - tombstones.length
if (!epochRetry && serverCount < expectedMin) {
await db.sync_mirror.clear()
return doRunSync(config, true)
}
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
const applyResult = await applySnapshot(serverTables, serverTombstones)
@@ -360,6 +391,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
ensureSSE(base, getToken() || token)
const at = nowLocalISO()
await putSyncLastTime(at)
if (epoch) localStorage.setItem(epochKey, epoch)
if (applyResult.changed > 0 || applyResult.removed > 0 || pushed > 0) notifyAttendanceChanged()
return { pushed, pulled: applyResult.changed, removed: applyResult.removed, errors: 0, at }
}
@@ -398,6 +430,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)
@@ -405,7 +438,9 @@ async function applySnapshot(
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r]))
const live: AnyRow[] = (serverTables[t] || []).filter(r => !dead.has(t + '|' + r.uuid))
const live: AnyRow[] = (serverTables[t] || [])
.filter(r => r && typeof r === 'object' && r.uuid)
.filter(r => !dead.has(t + '|' + r.uuid))
// 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑)
const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)))
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
@@ -431,6 +466,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 +489,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)
+38 -46
View File
@@ -87,36 +87,37 @@ watch(focusDate, d => {
focusDate.value = null
}, { immediate: true })
</script>
<template>
<div class="cal-page">
<div class="cal-toolbar">
<button class="icon-btn" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
<button class="icon-btn" title="上一月" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
<span class="cal-title num">{{ title }}</span>
<button class="icon-btn" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" @click="goToday">回到今天</button>
<button class="icon-btn" title="下一月" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" :disabled="monthRef === t" @click="goToday">回到本月</button>
</div>
<div class="cal-head">
<span v-for="w in WEEK_LABELS" :key="w" class="cal-week muted">{{ w }}</span>
</div>
<div class="cal-grid">
<button
v-for="(c, idx) in cells" :key="idx"
class="cal-cell"
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date }"
:title="c.inMonth ? cellTitle(c.date) : undefined"
@click="selectDay(c.date, c.inMonth)"
>
<template v-if="c.inMonth">
<span class="daynum num" :class="{ today: c.date === t }">{{ Number(c.date.slice(8, 10)) }}</span>
<span class="cal-marks">
<span v-if="statusOf(c.date)" class="dot" :class="statusOf(c.date)"></span>
<Icon v-if="hasLogOf(c.date)" name="check-circle" :size="12" class="mark-log" />
<Icon v-if="hasStarOf(c.date)" name="star" :size="12" class="mark-star" />
</span>
</template>
</button>
<div class="card cal-card">
<div class="cal-head">
<span v-for="w in WEEK_LABELS" :key="w" class="cal-week">{{ w }}</span>
</div>
<div class="cal-grid">
<button
v-for="(c, idx) in cells" :key="idx"
class="cal-cell"
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date, 'is-today': c.date === t }"
:title="c.inMonth ? cellTitle(c.date) : undefined"
@click="selectDay(c.date, c.inMonth)"
>
<template v-if="c.inMonth">
<span class="daynum num" :class="{ today: c.date === t }">{{ Number(c.date.slice(8, 10)) }}</span>
<span class="cal-marks">
<span v-if="statusOf(c.date)" class="dot" :class="statusOf(c.date)"></span>
<Icon v-if="hasLogOf(c.date)" name="check-circle" :size="12" class="mark-log" />
<Icon v-if="hasStarOf(c.date)" name="star" :size="12" class="mark-star" />
</span>
</template>
</button>
</div>
</div>
<transition name="drawer">
@@ -132,30 +133,24 @@ watch(focusDate, d => {
</transition>
</div>
</template>
<style scoped>
.cal-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.cal-title { font-size: 14px; font-weight: 600; min-width: 96px; text-align: center; }
.cal-head {
.cal-card { padding: 0; overflow: hidden; }
.cal-head, .cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
gap: 1px;
background: var(--border);
}
.cal-week {
background: var(--bg-subtle);
text-align: center;
padding: 6px 0;
padding: 7px 0;
font-size: 12px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
font-weight: 500;
color: var(--text-3);
}
.cal-cell {
min-height: 92px;
@@ -163,16 +158,15 @@ watch(focusDate, d => {
text-align: left;
background: var(--bg-page);
border: none;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
cursor: pointer;
display: block;
font-size: 13px;
transition: background 0.12s;
}
.cal-cell:hover { background: var(--bg-subtle); }
.cal-cell.blank { cursor: default; }
.cal-cell.blank:hover { background: var(--bg-page); }
.cal-cell.selected { background: var(--accent-bg); }
.cal-cell.blank { cursor: default; background: var(--bg-subtle); }
.cal-cell.is-today { background: #F5F9FF; }
.cal-cell.selected { background: var(--accent-bg); box-shadow: inset 0 0 0 2px var(--accent); }
.daynum.today { color: var(--accent); font-weight: 600; }
.cal-cell.weekend .daynum { color: var(--text-3); }
.cal-marks { display: flex; align-items: center; gap: 4px; margin-top: 8px; height: 14px; }
@@ -184,7 +178,7 @@ watch(focusDate, d => {
top: 0;
right: 0;
bottom: 0;
width: min(420px, 100%);
width: min(440px, 100%);
background: var(--bg-page);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-float);
@@ -208,9 +202,7 @@ watch(focusDate, d => {
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
@media (max-width: 720px) {
.cal-cell { min-height: 60px; padding: 4px 5px; }
.cal-title { min-width: 76px; font-size: 13px; }
.cal-marks { gap: 3px; margin-top: 4px; height: 12px; }
.drawer { width: 100%; }
}
</style>
+49 -117
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)
@@ -197,7 +214,6 @@ async function confirmDelete() {
showToast('账目已删除')
}
</script>
<template>
<div class="ledger-page">
<div class="ledger-toolbar">
@@ -216,58 +232,14 @@ 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">
<div class="card stat-card"><div class="muted">本月收入(元)</div><div class="stat-num num amt-income">{{ yuan(summary.income) }}</div></div>
<div class="card stat-card"><div class="muted">本月支出(元)</div><div class="stat-num num">{{ yuan(summary.expense) }}</div></div>
<div class="card stat-card"><div class="muted">本月结余(元)</div><div class="stat-num num" :class="{ 'amt-income': summary.balance >= 0, 'amt-expense': summary.balance < 0 }">{{ yuan(summary.balance) }}</div></div>
<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 class="card stat-card stat-income"><div class="muted">本月收入(元)</div><div class="stat-num num amt-income">{{ yuan(summary.income) }}</div></div>
<div class="card stat-card stat-expense"><div class="muted">本月支出(元)</div><div class="stat-num num">{{ yuan(summary.expense) }}</div></div>
<div class="card stat-card stat-balance"><div class="muted">本月结余(元)</div><div class="stat-num num" :class="{ 'amt-income': summary.balance >= 0, 'amt-expense': summary.balance < 0 }">{{ yuan(summary.balance) }}</div></div>
<div class="card stat-card stat-pending"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
</div>
<div class="card sec" v-if="categoryStats.length">
@@ -320,11 +292,11 @@ async function confirmDelete() {
</table>
</div>
<div class="muted vault-note num">筛选结果 {{ filtered.length }} 条 / 本月共 {{ rows.length }} 条</div>
<div class="muted table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
<TransactionDialog
:open="dialogOpen"
:title="editingId ? '编辑账目' : '编辑账目'"
:title="editingId ? '编辑账目' : '记一笔'"
:initial="dialogInitial"
@cancel="dialogOpen = false"
@save="onDialogSave"
@@ -347,69 +319,29 @@ async function confirmDelete() {
.w-filter { width: 110px; flex: none; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.stat-card { padding: 14px 16px; }
.stat-num { font-size: 20px; font-weight: 600; margin-top: 4px; }
.amt-income { color: var(--ok); }
.amt-expense { color: var(--text-1); }
.amt-warn { color: var(--warn); }
.stat-card { border-left: 3px solid var(--border); }
.stat-income { border-left-color: var(--ok); }
.stat-expense { border-left-color: var(--text-3); }
.stat-balance { border-left-color: var(--accent); }
.stat-pending { border-left-color: var(--warn); }
.sec { padding: 14px 16px; margin-top: 12px; }
.sec-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.form-hint { font-size: 12px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.form-footer {
display: flex; justify-content: flex-end; gap: 8px;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
}
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn {
height: 30px; padding: 0 14px; border: none;
background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 13px;
}
.seg-btn + .seg-btn { border-left: 1px solid var(--border); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.toggle-btn {
height: 32px; width: 100%;
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: var(--radius-sm);
cursor: pointer; font-size: 13px; color: var(--text-2);
}
.toggle-btn:hover { background: var(--bg-hover); }
.toggle-btn.on { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.card.sec, .card.section { margin-top: 12px; }
.bar-label { width: 56px; }
.bar-num { width: 118px; }
.bar-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.bar-label { width: 56px; flex: none; color: var(--text-2); font-size: 12px; text-align: right; }
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.bar-num { font-size: 12px; width: 110px; flex: none; }
.table-card { overflow-x: auto; }
.tx-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tx-table th {
text-align: left; font-weight: 500; color: var(--text-2);
background: var(--bg-subtle); padding: 10px 12px;
border-bottom: 1px solid var(--border); white-space: nowrap;
}
.tx-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; word-break: break-all; }
.tx-table tbody tr:hover { background: var(--bg-subtle); }
.tx-table tbody tr:last-child td { border-bottom: none; }
.td-note { color: var(--text-2); }
.tx-pending { color: var(--warn); font-size: 12px; }
.tx-done { color: var(--ok); font-size: 12px; }
.th-ops { width: 220px; }
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
.empty { text-align: center; padding: 24px 0; }
.vault-note { margin-top: 10px; font-size: 12px; text-align: right; }
.table-card { overflow-x: auto; }
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) {
.form-grid { grid-template-columns: 1fr; }
.field.span2 { grid-column: auto; }
.ledger-toolbar .w-filter { flex: 1; min-width: 0; width: auto; }
@media (max-width: 720px) {
.ledger-toolbar { gap: 8px; }
.ledger-toolbar .flex1 { display: none; }
.month-title { min-width: 0; flex: 1; text-align: left; }
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
}
</style>
+10 -31
View File
@@ -77,29 +77,32 @@ async function submit() {
<style scoped>
.login-page {
min-height: 100vh;
background: var(--bg-subtle);
min-height: 100dvh;
background: linear-gradient(180deg, #EBF0F8 0%, var(--bg-app) 46%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card .input { height: 38px; }
.login-card {
width: 400px;
max-width: 100%;
padding: 28px 26px;
padding: 30px 28px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-page);
box-shadow: var(--shadow-float);
border-radius: var(--radius-lg);
}
.login-brand { text-align: center; margin-bottom: 6px; }
.brand-icon {
width: 46px;
height: 46px;
margin: 0 auto 10px;
border-radius: var(--radius-md);
background: var(--accent-bg);
color: var(--accent);
border-radius: 12px;
background: var(--accent);
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
@@ -107,31 +110,7 @@ async function submit() {
.login-title { font-size: 18px; font-weight: 600; color: var(--text-1); }
.login-sub { font-size: 12px; margin-top: 4px; line-height: 1.7; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn {
flex: 1;
height: 30px;
border: none;
background: var(--bg-page);
color: var(--text-2);
cursor: pointer;
font-size: 13px;
}
.seg-btn + .seg-btn { border-left: 1px solid var(--border); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.mig-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
line-height: 1.6;
}
.mig-row input { margin-top: 3px; }
.form-error { color: var(--danger); font-size: 12px; }
.login-btn { margin-top: 2px; }
.login-btn { margin-top: 2px; height: 36px; }
.login-note { font-size: 12px; text-align: center; line-height: 1.7; }
</style>
+10 -7
View File
@@ -197,13 +197,13 @@ function fmtTime(s: string): string {
.memo-list {
display: flex;
flex-direction: column;
max-height: calc(100vh - 140px);
max-height: calc(100vh - 150px);
}
.pane-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
padding: 12px 12px 10px;
border-bottom: 1px solid var(--border);
flex: none;
}
@@ -223,12 +223,12 @@ function fmtTime(s: string): string {
.memo-item-time { font-size: 11px; margin-top: 2px; }
.memo-editor {
padding: 16px;
padding: 16px 18px;
min-height: 420px;
display: flex;
flex-direction: column;
}
.title-input { font-size: 14px; font-weight: 500; margin-bottom: 10px; }
.title-input { font-size: 15px; font-weight: 600; margin-bottom: 10px; }
.content-textarea { flex: 1; min-height: 340px; line-height: 1.8; }
.editor-foot {
display: flex;
@@ -248,8 +248,11 @@ function fmtTime(s: string): string {
}
@media (max-width: 800px) {
.memo-page { grid-template-columns: 1fr; }
.memo-list { max-height: 240px; }
.content-textarea { min-height: 240px; }
.memo-page { grid-template-columns: 1fr; gap: 10px; }
.memo-list { max-height: 210px; }
.content-textarea { min-height: 260px; }
.title-input { height: 38px; }
.editor-foot { flex-wrap: wrap; }
.editor-foot .btn { flex: 1; min-height: 34px; }
}
</style>
+480
View File
@@ -0,0 +1,480 @@
<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, notifyPlanTimer } 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)
let liveExtra = 0
const runningId = running.value
if (runningId) {
const rp = items.value.find(p => p.id === runningId)
if (rp) liveExtra = liveElapsed(rp)
}
return { total, done, plannedMin, actualMin: actualMin + liveExtra }
})
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)
}
notifyPlanTimer()
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="blk-head">
<div class="blk-head-text">
<div class="blk-title">长期计划</div>
<div class="muted blk-sub">跨越多天的目标,自动落实到每一天</div>
</div>
<button class="btn btn-sm" @click="openGoalCreate"><Icon name="plus" :size="14" /> 新建长期计划</button>
</div>
<div v-if="!goals.length" class="card empty muted">
暂无长期计划。创建一个跨越多天的目标(如「产品文档学习」),系统会自动生成每天的打卡任务。
</div>
<div v-for="g in goals" :key="g.id" class="card 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) + '%',
background: goalProg[String(g.id)] && goalProg[String(g.id)].done >= goalProg[String(g.id)].total && goalProg[String(g.id)].total > 0 ? 'var(--ok)' : 'var(--accent)'
}"
></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 class="blk-head day-head">
<div class="blk-head-text">
<div class="blk-title">每日安排</div>
<div class="muted blk-sub num">
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
<template v-if="running"> · <span class="timer-live">计时中</span></template>
</div>
</div>
<div class="day-ops">
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
<span class="date-chip num" :class="{ today: isToday }">{{ date.slice(5) }} {{ weekday(date) }}</span>
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" :class="{ 'btn-primary': !isToday }" @click="goToday">今天</button>
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
</div>
</div>
<div class="card list-card">
<div v-if="!loading && !items.length" class="empty muted">今天还没有计划,点「添加计划」开始规划。</div>
<div
v-for="p in items" :key="p.id"
class="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="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="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; }
.blk-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
margin: 4px 2px 10px;
}
.blk-head-text { min-width: 0; }
.blk-title { font-size: 14px; font-weight: 600; }
.blk-sub { font-size: 12px; margin-top: 1px; }
.day-head { margin-top: 18px; }
.day-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.date-chip { min-width: 74px; text-align: center; font-weight: 600; font-size: 13px; }
.date-chip.today { color: var(--accent); }
.timer-live { color: var(--accent); font-weight: 600; }
.goal-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; margin-bottom: 10px; }
.goal-main { flex: 1; min-width: 0; }
.goal-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.goal-name { font-size: 14px; font-weight: 500; word-break: break-all; }
.goal-range { font-size: 12px; }
.goal-bar { height: 6px; border-radius: 3px; background: var(--bg-subtle); overflow: hidden; margin-top: 10px; }
.goal-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
.goal-meta { font-size: 12px; margin-top: 6px; }
.goal-ops { display: flex; gap: 4px; }
.list-card { overflow: hidden; }
.plan-item {
display: flex; align-items: flex-start; gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.plan-item:last-child { border-bottom: none; }
.plan-item.done { opacity: 0.6; }
.plan-item.skip { opacity: 0.48; }
.plan-item.running { box-shadow: inset 3px 0 0 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); 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 { padding: 28px 16px; }
.dialog { width: 420px; }
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
.form-row.two { flex-direction: row; gap: 10px; }
.form-row.two > div { flex: 1; display: flex; flex-direction: column; gap: 5px; }
.goal-hint { font-size: 12px; margin-top: 8px; }
@media (max-width: 720px) {
.blk-head { margin: 0 0 10px; }
.day-head { margin-top: 16px; }
.day-ops { width: 100%; }
.day-ops .btn { flex: 1; min-width: 0; }
.day-ops .btn-primary { flex: 1.5; }
.date-chip { min-width: 64px; }
.plan-item { flex-wrap: wrap; padding: 12px; gap: 10px; }
.pi-time { min-width: 0; padding-top: 0; }
.pi-ops { width: 100%; justify-content: flex-start; gap: 8px; }
.pi-ops .btn-xs { flex: 1; min-height: 32px; }
.goal-card { position: relative; flex-direction: column; gap: 8px; }
.goal-main { padding-right: 0; }
.goal-line { padding-right: 72px; }
.goal-ops { position: absolute; top: 10px; right: 8px; }
}
</style>
+11 -14
View File
@@ -210,21 +210,10 @@ function undoPolish() {
<style scoped>
.report-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.ai-error { color: var(--danger); font-size: 12px; margin: 0 0 8px; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 6px; overflow: hidden; }
.seg-btn {
height: 28px; padding: 0 12px; border: none;
background: var(--bg-page); color: var(--text-2);
cursor: pointer; font-size: 12px;
border-right: 1px solid var(--border);
}
.seg-btn:last-child { border-right: none; }
.seg-btn:hover { background: var(--bg-hover); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.w-date { width: 140px; }
.w-date { width: 140px; flex: none; }
.report-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; align-items: start; }
.section { padding: 14px 16px; }
.section-title { font-weight: 600; margin-bottom: 8px; font-size: 13px; }
.section-title { margin-bottom: 10px; }
.section + .section { margin-top: 12px; }
.note-label { display: block; font-size: 12px; margin: 8px 0 4px; }
@@ -232,11 +221,19 @@ function undoPolish() {
.md-editor { min-height: 420px; line-height: 1.7; }
.sum-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; }
.section-sub { font-weight: 600; margin: 10px 0 4px; font-size: 12px; }
.sum-row span:last-child { font-weight: 500; }
.section-sub { font-weight: 600; margin: 12px 0 4px; font-size: 12px; color: var(--text-2); }
.sum-item { font-size: 12px; padding: 2px 0; word-break: break-all; }
.sum-empty { font-size: 12px; }
@media (max-width: 960px) {
.report-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.report-toolbar { gap: 8px; }
.report-toolbar .seg { flex: 1 1 100%; display: flex; }
.report-toolbar .seg-btn { flex: 1; }
.w-date { flex: 1; min-width: 0; width: auto; }
.md-editor { min-height: 300px; }
}
</style>
+101 -25
View File
@@ -8,13 +8,13 @@ import {
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
} from '../db/repo'
import { categoryLabel, 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
@@ -428,18 +494,16 @@ async function doImport() {
<style scoped>
.settings { display: flex; flex-direction: column; gap: 12px; max-width: 640px; }
.acc-line { font-size: 12px; margin-bottom: 10px; line-height: 1.7; }
.section { padding: 14px 16px; }
.section-title { font-weight: 600; margin-bottom: 10px; font-size: 13px; }
.rule-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; }
.rule-item label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.rule-save { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.rule-save { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.proj-add { display: flex; gap: 8px; }
.proj-tip { font-size: 12px; margin-top: 6px; }
.proj-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 0; border-bottom: 1px solid var(--border);
padding: 7px 0; border-bottom: 1px solid var(--border);
}
.proj-row:last-child { border-bottom: none; }
.proj-name { flex: 1; word-break: break-all; }
@@ -447,7 +511,19 @@ async function doImport() {
.loc-note { font-size: 12px; }
.ai-grid { display: flex; flex-direction: column; gap: 10px; }
.backup-row { display: flex; gap: 8px; }
.backup-row { display: flex; gap: 8px; flex-wrap: wrap; }
.cat-block { margin-top: 12px; }
.cat-block:first-of-type { margin-top: 0; }
.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;
background: var(--bg-subtle);
}
.chip-btn { border: none; background: transparent; color: var(--text-3); cursor: pointer; font-size: 11px; padding: 0 1px; }
.chip-btn:hover { color: var(--accent); }
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.backup-note { font-size: 12px; margin-top: 10px; }
</style>
+21 -20
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)
@@ -175,32 +180,28 @@ function heatTitle(date: string, count: number): string {
<style scoped>
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.stat-card { padding: 14px 16px; }
.stat-num { font-size: 20px; font-weight: 600; margin-top: 4px; }
.section { padding: 14px 16px; margin-top: 12px; }
.section-title { font-weight: 600; margin-bottom: 10px; font-size: 13px; }
.section { margin-top: 12px; }
.heat { display: flex; gap: 3px; overflow-x: auto; padding-bottom: 4px; }
.heat-col { display: flex; flex-direction: column; gap: 3px; }
.heat-cell { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }
.heat-cell { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
.lv0 { background: var(--bg-subtle); border: 1px solid var(--border); }
.lv1 { background: #D8ECDF; }
.lv2 { background: #A9D8BC; }
.lv3 { background: #6FBF92; }
.lv4 { background: var(--ok); }
.heat-legend { display: flex; align-items: center; gap: 3px; margin-top: 8px; font-size: 12px; }
.lv1 { background: #E4EEFF; }
.lv2 { background: #BFD6FF; }
.lv3 { background: #7FA8F5; }
.lv4 { background: var(--accent); }
.heat-legend { display: flex; align-items: center; gap: 3px; margin-top: 10px; font-size: 12px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.bar-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.bar-label { width: 72px; flex: none; color: var(--text-2); font-size: 12px; text-align: right; }
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.bar-num { font-size: 12px; width: 92px; flex: none; }
.two-col .section { margin-top: 0; }
.bar-label { width: 72px; }
.bar-num { width: 92px; }
.loc-label { width: 110px; text-align: left; }
@media (max-width: 720px) {
.cards { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.two-col .section { margin-top: 12px; }
}
</style>
+117 -17
View File
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } 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,40 +16,135 @@ 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: date.value, end: date.value, label: date.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
}
</script>
// 浏览日期变化时,导出区间锚点跟随(周/月/自定义默认围绕当前指定日)
watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.value = d })
</script>
<template>
<div class="detail-page">
<div class="detail-toolbar">
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
<span class="date-title num" :class="{ 'is-today': isToday }">{{ formatDateCN(date) }}</span>
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
<div class="card toolbar">
<div class="nav-row">
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
<span class="date-title num" :class="{ 'is-today': isToday }">{{ formatDateCN(date) }}</span>
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
</div>
<div class="tool-sep"></div>
<div class="exp-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>
</div>
<DayPanel :date="date" />
</div>
</template>
<style scoped>
.detail-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
.detail-page { display: flex; flex-direction: column; }
.toolbar {
padding: 10px 16px;
display: flex; align-items: center; gap: 14px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.nav-row { display: flex; align-items: center; gap: 6px; }
.date-title { font-size: 14px; font-weight: 600; min-width: 170px; text-align: center; }
.date-title.is-today { color: var(--accent); }
.w-date { width: 150px; }
.w-date { width: 150px; flex: none; }
.tool-sep { width: 1px; align-self: stretch; background: var(--border); }
.exp-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.exp-label { font-size: 12px; }
.w-exp { width: 140px; flex: none; }
@media (max-width: 720px) {
.detail-toolbar { gap: 8px; }
.tool-sep { display: none; }
.toolbar { padding: 10px 12px; gap: 10px; }
.nav-row { flex: 1 1 100%; }
.date-title { min-width: 0; flex: 1; text-align: left; }
.w-date { flex: 1; min-width: 120px; width: auto; }
.exp-row { width: 100%; }
.exp-row .seg { flex: 1; display: flex; }
.exp-row .seg-btn { flex: 1; padding: 0 6px; }
.w-exp { flex: 1; min-width: 0; width: auto; }
}
</style>
+27 -45
View File
@@ -150,24 +150,20 @@ async function doDelete() {
onMounted(load)
</script>
<template>
<div class="admin-page">
<div class="card sec">
<div class="card table-card">
<div class="toolbar">
<div class="head-wrap">
<div class="sec-title">用户管理</div>
<div class="muted head-sub">云端账号统一管理 · 管理员:xionglin</div>
<div class="tb-title">用户管理</div>
<div class="muted tb-sub">云端账号统一管理 · 管理员:xionglin</div>
</div>
<button class="btn btn-primary btn-sm" @click="openCreate">
<Icon name="plus" :size="14" />
新增用户
</button>
</div>
<div v-if="error" class="form-error">{{ error }}</div>
</div>
<div class="card table-card admin-table-card">
<div v-if="error" class="form-error tb-error">{{ error }}</div>
<table class="admin-table">
<thead>
<tr>
@@ -202,8 +198,8 @@ onMounted(load)
<!-- 新增用户弹窗 -->
<teleport to="body">
<div v-if="createOpen" class="fixed-mask" @click.self="createOpen = false">
<div class="dialog create-dialog" role="dialog" aria-label="新增用户">
<div v-if="createOpen" class="mask" @click.self="createOpen = false">
<div class="dialog" role="dialog" aria-label="新增用户">
<div class="dialog-head">
<span class="dialog-title">新增用户</span>
<button class="icon-btn" @click="createOpen = false"><Icon name="close" :size="16" /></button>
@@ -233,8 +229,8 @@ onMounted(load)
<!-- 重置密码弹窗 -->
<teleport to="body">
<div v-if="resetTarget" class="fixed-mask" @click.self="resetTarget = null">
<div class="dialog create-dialog" role="dialog" aria-label="重置密码">
<div v-if="resetTarget" class="mask" @click.self="resetTarget = null">
<div class="dialog" role="dialog" aria-label="重置密码">
<div class="dialog-head">
<span class="dialog-title">重置「{{ resetTarget.username }}」的登录密码</span>
<button class="icon-btn" @click="resetTarget = null"><Icon name="close" :size="16" /></button>
@@ -264,60 +260,46 @@ onMounted(load)
/>
</div>
</template>
<style scoped>
.admin-page { display: flex; flex-direction: column; gap: 12px; max-width: 860px; }
.toolbar { display: flex; align-items: center; gap: 12px; }
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.head-wrap { flex: 1; min-width: 0; }
.sec-title { font-weight: 600; font-size: 13px; }
.head-sub { font-size: 12px; margin-top: 2px; }
.sec { padding: 14px 16px; }
.tb-title { font-weight: 600; font-size: 14px; }
.tb-sub { font-size: 12px; margin-top: 2px; }
.tb-error { padding: 8px 16px 0; }
.admin-table-card { overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.admin-table th { text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.admin-table td:last-child { border-bottom: none; }
.table-card { overflow-x: auto; }
.admin-table td { white-space: nowrap; }
.td-name { font-weight: 500; }
.role-badge {
display: inline-flex;
align-items: center;
border: 1px solid var(--accent);
background: var(--accent-bg);
color: var(--accent);
border-radius: 999px;
font-size: 12px;
line-height: 16px;
padding: 1px 8px;
line-height: 18px;
padding: 1px 9px;
}
.load-line { padding: 12px; font-size: 12px; }
.load-line { padding: 12px 16px; font-size: 12px; }
.admin-note { font-size: 12px; padding: 0 2px; }
.ops { display: inline-flex; gap: 6px; }
.btn-danger { color: var(--danger); }
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
.form-row label { font-size: 12px; color: var(--text-2); }
.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; }
.btn-danger { color: var(--danger); }
.dialog { width: 420px; }
.empty { text-align: center; }
@media (max-width: 720px) {
.toolbar { flex-wrap: wrap; row-gap: 8px; }
.admin-table td, .admin-table th { white-space: normal; }
}
</style>
+7 -57
View File
@@ -284,7 +284,7 @@ async function doChangePwd() {
</tbody>
</table>
</div>
<div class="muted vault-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密,服务器仅存密文</div>
<div class="muted table-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密,服务器仅存密文</div>
</template>
<CredentialDialog
@@ -356,18 +356,15 @@ async function doChangePwd() {
height: 52px;
margin: 0 auto;
border-radius: 50%;
background: var(--bg-subtle);
border: 1px solid var(--border);
background: var(--accent-bg);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-2);
}
.lock-title { font-size: 14px; font-weight: 600; }
.lock-title { font-size: 15px; font-weight: 600; }
.lock-tip { font-size: 12px; line-height: 1.7; }
.lock-btn { margin-top: 4px; }
.form-error { color: var(--danger); font-size: 12px; }
.empty { text-align: center; padding: 24px 0; }
.vault-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.search-wrap { position: relative; width: 280px; max-width: 100%; }
@@ -375,63 +372,16 @@ async function doChangePwd() {
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
.table-card { overflow-x: auto; }
.cred-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.cred-table th {
text-align: left;
font-weight: 500;
color: var(--text-2);
background: var(--bg-subtle);
padding: 10px 12px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.cred-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; word-break: break-all; }
.cred-table tbody tr:hover { background: var(--bg-subtle); }
.cred-table tbody tr:last-child td { border-bottom: none; }
.td-system { font-weight: 500; }
.th-ops { width: 150px; }
.pwd-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.pwd-text { letter-spacing: 1px; }
.pwd-text { letter-spacing: 1px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.copyable { cursor: pointer; }
.copyable:hover { color: var(--accent); }
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
.vault-note { margin-top: 10px; font-size: 12px; text-align: right; }
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 440px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
}
.dialog { width: 440px; }
.vp-field { margin-bottom: 10px; }
.vp-field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.vp-tip { font-size: 12px; margin-top: 8px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
BIN
View File
Binary file not shown.
+25 -4
View File
@@ -14,10 +14,11 @@
cd /opt
git clone https://gitea.linyux.cn/root/daywork.git daywork 2>/dev/null || (cd daywork && git pull)
cd daywork/server
rm -rf data
docker compose up -d --build
注:rm -rf data 清除开发期残留的测试密钥,服务端首次启动会自动生成全新的签名密钥。
**警告:绝对不要删除 server/data 目录** —— 这里是所有用户的正式数据(注册表、业务数据、每日备份)。
首次全新部署(且确认 data 目录从未存在过)无需任何清理动作,服务端首次启动会自动生成签名密钥。
另外,客户端内置「数据纪元」保护:即使服务器数据目录意外重建,任一保留本地缓存的设备在下次同步时会自动把全量数据推回服务器。
容器 internlog-sync 加入 mynet 网络(与 caddy 同网络,Caddy 可用容器名访问)。
@@ -61,8 +62,28 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
- 数据目录:/opt/daywork/server/data
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
- users/<id>.json 每用户九张表数据 + 墓碑
- backups/<日期>/ **每日自动备份**(启动与每次注册表变更也会触发,保留最近 14 天)
- users/<id>.json 每用户全部业务表数据 + 墓碑
- backups/<日期>/ 每日快照。**当日首份不可变**(完整证据链),同日再备份写入 `<日期>_t<时分秒>` 副本
- backups/weekly/<日期>/ 每周一额外深快照,保留 8 份
- 保留策略:每日快照 14 天;写入均为「临时文件 + fsync + 原子改名」,断电不会留下半截文件
- 用户数据文件若解析失败会被自动隔离为 `<文件>.corrupt-<时间戳>` 并在日志中大声报错,绝不静默当作空数据
- 墓碑自动回收:删除标记保留 14 天后自动清除(足够覆盖离线设备),文件不会无限膨胀
- 单次同步携带 ≥20 条墓碑(大批删除)时,服务端会**先自动快照再应用删除**,可随时回滚
- **异地备份(推荐开启)**:在 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)
+52
View File
@@ -0,0 +1,52 @@
// 表结构自愈冒烟:数组形态 + null 行 + 合法行混合 → 同步一次 → 治愈为 uuid 键对象且数据完整
const BASE = 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name, ok, detail) {
if (ok) { pass++; console.log(' PASS ' + name) } else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function j(path, body, token) {
const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined })
return { status: r.status, json: await r.json().catch(() => ({})) }
}
async function main() {
const fsp = await import('fs')
const reg = await j('/api/auth/register', { username: 'healtest', password: 'test123456' })
const tok = reg.json.token
const file = 'server/data/users/' + reg.json.userId + '.json'
// 构造坏文件:work_log 是数组 + 含 null 槽位;attendance 数组含 null;memo 正常对象但混入 null 行
const bad = {
userId: reg.json.userId, cursor: 0,
created_at: '2026-10-26T12:00:00.000Z',
tables: {
work_log: [ { uuid: 'wl1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, null, { uuid: 'wl2', date: '2026-10-21', created_at: '2026-10-21T00:00:00', updated_at: '2026-10-21T00:00:00' } ],
attendance: [ null, { uuid: 'at1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' } ],
memo: { m1: { uuid: 'm1', title: '幸存备忘', content: '', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, bad: null },
plan: []
},
tombstones: { memo: { g1: null }, work_log: {} }
}
fsp.mkdirSync('server/data/users', { recursive: true })
fsp.writeFileSync(file, JSON.stringify(bad))
// 推送一行 work_log(自然键 date 扫描会遍历表)→ 修复前这里 500,修复后应 200
const r1 = await j('/api/sync', { device: 't', tables: { work_log: [ { uuid: 'wl3', date: '2026-10-22', created_at: '2026-10-22T00:00:00', updated_at: '2026-10-22T00:00:00' } ] }, tombstones: [] }, tok)
check('同步 200(不再读 null.date)', r1.status === 200, 'status=' + r1.status)
const healed = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('work_log 已转 uuid 键对象', !Array.isArray(healed.tables.work_log) && !!healed.tables.work_log.wl1 && !!healed.tables.work_log.wl3)
check('null 行已剔除', !('1' in (healed.tables.work_log || {})) && !healed.tables.attendance['0'])
check('attendance 幸存行保留', healed.tables.attendance && healed.tables.attendance.at1)
check('memo 正常对象保留', healed.tables.memo && healed.tables.memo.m1)
check('null 墓碑已清理', !healed.tombstones.memo || !healed.tombstones.memo.g1)
check('全表均为对象形态', Object.values(healed.tables).every(x => x && !Array.isArray(x) && typeof x === 'object'))
// 推送 m1 更新(memo 无自然键)→ 再拉全量,快照中不得出现 null
await j('/api/sync', { device: 't', tables: { memo: [ { uuid: 'm2', title: '新备忘', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' } ] }, tombstones: [] }, tok)
const r3 = await j('/api/sync', { device: 't', tables: {}, tombstones: [] }, tok)
const flat = Object.values(r3.json.tables || {}).flat()
check('权威快照不含 null/无uuid行', r3.status === 200 && flat.every(x => x && typeof x === 'object' && x.uuid), JSON.stringify(flat.filter(x => !x || !x.uuid)).slice(0, 100))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+254 -40
View File
@@ -11,8 +11,13 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
const PORT = Number(process.env.PORT || 3780)
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
const ITER = 150000
const BACKUP_DAYS = 14 // 每日快照保留天数
const WEEKLY_KEEP = 8 // 每周深快照保留份数
const TOMBSTONE_TTL_MS = 14 * 24 * 60 * 60 * 1000 // 墓碑保留 14 天:足够覆盖离线设备,防止无限膨胀与重放
const LARGE_TOMBSTONE_BACKUP = 20 // 单次同步墓碑达到该数量时,先做数据快照再应用(大批删除的前置保险)
let lastBackupAt = ''
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 = {
@@ -35,10 +40,79 @@ function readJson(file, fallback) {
}
function writeJson(file, data) {
const tmp = path.join(DATA_DIR, file + '.tmp')
fs.writeFileSync(tmp, JSON.stringify(data))
const fd = fs.openSync(tmp, 'w')
try {
fs.writeSync(fd, JSON.stringify(data))
fs.fsyncSync(fd) // 落盘后再原子改名:进程崩溃/断电不会留下半截文件
} finally {
fs.closeSync(fd)
}
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 +129,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
@@ -63,8 +137,77 @@ function isAdminToken(token) {
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
function emptyUserData(userId) {
return { userId, cursor: 0, tables: {}, tombstones: {}, created_at: '' }
}
// 表结构自愈:tables[t] 必须是「uuid -> 行对象」;
// 兼容历史误存为数组的形态(并剔除 null / 非法行),避免自然键扫描在 null 上崩溃、避免序列化丢弃行
function sanitizeTables(data) {
let healed = false
const src = data.tables && typeof data.tables === 'object' && !Array.isArray(data.tables) ? data.tables : null
if (!src) { data.tables = {}; return true }
const out = {}
for (const t of Object.keys(src)) {
const container = src[t]
if (Array.isArray(container)) {
healed = true
const m = {}
for (const row of container) {
if (row && typeof row === 'object' && row.uuid) m[row.uuid] = row
}
out[t] = m
continue
}
if (!container || typeof container !== 'object') { healed = true; out[t] = {}; continue }
const m = {}
for (const k of Object.keys(container)) {
const row = container[k]
if (!row || typeof row !== 'object' || Array.isArray(row) || !row.uuid) { healed = true; continue }
m[k] = row
}
out[t] = m
}
data.tables = out
if (!data.tombstones || typeof data.tombstones !== 'object' || Array.isArray(data.tombstones)) { data.tombstones = {}; healed = true }
for (const t of Object.keys(data.tombstones)) {
const bucket = data.tombstones[t]
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) { delete data.tombstones[t]; healed = true; continue }
for (const k of Object.keys(bucket)) {
if (!bucket[k]) { delete bucket[k]; healed = true }
}
}
return healed
}
function loadUserData(userId) {
return readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
const full = userFile(userId)
if (fs.existsSync(full)) {
try {
const data = JSON.parse(fs.readFileSync(full, 'utf-8'))
const healed = sanitizeTables(data)
return { data, healed }
} catch (e) {
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
console.error('[存储] 用户数据文件解析失败,已隔离:' + quarantine + '(' + (e && e.message) + ')')
return { data: emptyUserData(userId), healed: false }
}
}
return { data: emptyUserData(userId), healed: false }
}
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
function gcTombstones(data) {
const cutoff = Date.now() - TOMBSTONE_TTL_MS
let pruned = 0
for (const t of Object.keys(data.tombstones || {})) {
const bucket = data.tombstones[t]
for (const u of Object.keys(bucket)) {
const ts = Date.parse(bucket[u])
if (Number.isFinite(ts) && ts < cutoff) { delete bucket[u]; pruned++ }
}
if (!Object.keys(bucket).length) delete data.tombstones[t]
}
return pruned
}
function saveUserData(userId, data) {
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
@@ -76,7 +219,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 +238,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 +346,20 @@ 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,
users: registry.users.length,
lastBackup: lastBackupAt
})
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 +378,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 +386,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 +395,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,23 +421,35 @@ 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
if (!userData.tables) userData.tables = {}
if (!userData.tombstones) userData.tombstones = {}
const loaded = loadUserData(auth.userId)
const userData = loaded.data
let dirty = loaded.healed
if (userData.cursor == null) { userData.cursor = 0; dirty = true }
if (!userData.tables) { userData.tables = {}; dirty = true }
if (!userData.tombstones) { userData.tombstones = {}; dirty = true }
if (!userData.created_at) { userData.created_at = new Date().toISOString(); dirty = true } // 数据纪元(客户端灾难自检依据)
// 应用推送的变更(新者胜 + 自然键去重)
const incomingTables = body.tables || {}
@@ -293,7 +470,9 @@ async function handle(req, res) {
if (nkVal) {
for (const u2 of Object.keys(userData.tables[t])) {
if (u2 === row.uuid) continue
if (String(userData.tables[t][u2][nk] || '') === nkVal) { conflictUuid = u2; break }
const other = userData.tables[t][u2]
if (!other || typeof other !== 'object') continue
if (String(other[nk] || '') === nkVal) { conflictUuid = u2; break }
}
}
}
@@ -322,12 +501,18 @@ async function handle(req, res) {
userData.tombstones[t][conflictUuid] = now
changed++
}
// 内容与已存行完全一致:不计变更、不触发保存(空推送不落盘)
if (!conflictUuid && existing && JSON.stringify(existing) === JSON.stringify(clean)) continue
userData.tables[t][row.uuid] = clean
changed++
}
}
// 应用墓碑
const incomingTombs = body.tombstones || []
if (incomingTombs.length >= LARGE_TOMBSTONE_BACKUP) {
// 大批删除的前置保险:先把当前数据快照留存,再应用删除(可追溯、可回滚)
try { doBackup(); console.log('[存储] 检测到大批删除(' + incomingTombs.length + ' 条墓碑),已先行快照') } catch { /* 忽略 */ }
}
for (const tb of incomingTombs) {
if (!TABLES.includes(tb.table) || !tb.uuid) continue
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
@@ -342,10 +527,11 @@ async function handle(req, res) {
const tombs = userData.tombstones[t] || {}
for (const [u, deletedAt] of Object.entries(tombs)) {
const row = userData.tables[t][u]
if (row && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
if (row && typeof row === 'object' && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
}
}
saveUserData(auth.userId, userData)
if (gcTombstones(userData) > 0) dirty = true
if (dirty || changed > 0) saveUserData(auth.userId, userData)
if (changed) broadcast(auth.userId, 'change')
// 返回权威全量
@@ -356,7 +542,7 @@ async function handle(req, res) {
const tombs = userData.tombstones[t] || {}
for (const [u, deletedAt] of Object.entries(tombs)) outTombs.push({ table: t, uuid: u, deleted_at: deletedAt })
}
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString() })
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString(), dataEpoch: String(userData.created_at || '') })
return
}
@@ -422,28 +608,55 @@ async function handle(req, res) {
}
// ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ----------
function snapshotTo(dir) {
fs.mkdirSync(path.join(dir, 'users'), { recursive: true })
try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ }
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) {
if (f.endsWith('.json') && !f.includes('.corrupt-')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
}
function pruneBackups() {
try {
const root = path.join(DATA_DIR, 'backups')
const entries = fs.readdirSync(root).filter(d => /^\d{4}-\d{2}-\d{2}/.test(d))
const days = Array.from(new Set(entries.map(d => d.slice(0, 10)))).sort()
while (days.length > BACKUP_DAYS) {
const dead = days.shift()
for (const d of entries) {
if (d.startsWith(dead)) { try { fs.rmSync(path.join(root, d), { recursive: true, force: true }) } catch { /* 忽略 */ } }
}
}
} catch { /* 忽略 */ }
}
function doBackup() {
try {
const day = new Date().toISOString().slice(0, 10)
const dir = path.join(DATA_DIR, 'backups', day)
fs.mkdirSync(path.join(dir, 'users'), { recursive: true })
try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ }
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) {
if (f.endsWith('.json')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
// 保留最近 14 天
try {
const backupsRoot = path.join(DATA_DIR, 'backups')
const days = fs.readdirSync(backupsRoot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
const now = new Date()
const day = now.toISOString().slice(0, 10)
const root = path.join(DATA_DIR, 'backups')
let dir = path.join(root, day)
// 当日首份快照不可变(当日完整证据链);之后的同日备份落时间戳副本
if (fs.existsSync(dir)) dir = path.join(root, day + '_t' + now.toTimeString().slice(0, 8).replace(/:/g, ''))
snapshotTo(dir)
// 每周一额外做周快照(更深恢复点),保留 8 份
if (now.getDay() === 1) {
const wdir = path.join(root, 'weekly', day)
if (!fs.existsSync(wdir)) snapshotTo(wdir)
try {
const wroot = path.join(root, 'weekly')
const ws = fs.readdirSync(wroot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
while (ws.length > WEEKLY_KEEP) { const old = ws.shift(); try { fs.rmSync(path.join(wroot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
}
pruneBackups()
lastBackupAt = new Date().toISOString()
} catch (e) { console.error('backup failed:', e && e.message) }
void backupOffsite()
}
doBackup()
setInterval(doBackup, 24 * 60 * 60 * 1000)
@@ -451,7 +664,8 @@ setInterval(doBackup, 24 * 60 * 60 * 1000)
// ---------- HTTP 服务 ----------
const server = http.createServer((req, res) => {
handle(req, res).catch(err => {
json(res, 500, { error: err.message || 'server error' })
console.error('[api] ' + req.method + ' ' + req.url + ' -> 500: ' + ((err && (err.stack || err.message)) || String(err)))
json(res, 500, { error: (err && err.message) || 'server error' })
})
})
+61
View File
@@ -0,0 +1,61 @@
// 服务端存储加固冒烟:空同步不落盘 / 墓碑GC / 大批删除前置快照 / 损坏隔离
const BASE = 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name, ok, detail) {
if (ok) { pass++; console.log(' PASS ' + name) } else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
async function j(path, body, token) {
const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined })
return { status: r.status, json: await r.json().catch(() => ({})) }
}
async function main() {
const fsp = await import('fs')
let reg = await j('/api/auth/register', { username: 'storagetest', password: 'test123456' })
if (reg.status === 409) reg = await j('/api/auth/login', { username: 'storagetest', password: 'test123456' })
console.log('auth status:', reg.status)
const tok = reg.json.token
const file = 'server/data/users/' + reg.json.userId + '.json'
const row = { uuid: 'm1', title: 't', content: 'c', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' }
const push = tables => j('/api/sync', { device: 't', tables, tombstones: [] }, tok)
const p1 = await push({ memo: [row] })
console.log('push1 status:', p1.status)
check('首次同步落盘(200+文件存在)', p1.status === 200 && fsp.existsSync(file))
const m1 = fsp.statSync(file).mtimeMs
await sleep(30)
const p2 = await push({ memo: [row] })
await sleep(30)
const m2 = fsp.statSync(file).mtimeMs
check('空推送不重写文件(脏检查)', p2.status === 200 && m2 === m1, 'mtime ' + m1 + ' -> ' + m2)
await j('/api/sync', { device: 't', tables: {}, tombstones: [{ table: 'memo', uuid: 'old1', deleted_at: '2026-09-01T00:00:00' }] }, tok)
const d = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('过期墓碑(>14天)自动回收', !d.tombstones.memo || !d.tombstones.memo.old1, JSON.stringify(d.tombstones))
const dirBefore = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x))
const tombs = Array.from({ length: 20 }, (_, i) => ({ table: 'memo', uuid: 'ghost' + i, deleted_at: '2026-10-26T23:59:00' }))
tombs.push({ table: 'memo', uuid: 'm1', deleted_at: '2026-10-26T23:59:00' }) // m1 本尊:应被删除
await sleep(1100) // 错开秒级时间戳,避免 _t 目录同名覆盖
const bigResp = await j('/api/sync', { device: 't', tables: {}, tombstones: tombs }, tok)
console.log('big-tombstone status:', bigResp.status)
const dirAfter = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x))
console.log('backup dirs:', dirAfter.join(' | '))
check('大批删除触发前置快照', dirAfter.length > dirBefore.length, dirBefore.length + '->' + dirAfter.length)
const d2 = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('墓碑应用后行已删除', !d2.tables.memo || !d2.tables.memo.m1, JSON.stringify(Object.keys(d2.tables.memo || {})))
check('墓碑已记录(待客户端收敛)', Object.keys(d2.tombstones.memo || {}).length >= 21, JSON.stringify(d2.tombstones.memo || {}))
fsp.writeFileSync(file, '{ this is not json !!!')
const r5 = await push({ memo: [{ uuid: 'm2', title: 'x', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' }] })
check('损坏文件不再静默当空(同步仍200)', r5.status === 200, 'status=' + r5.status)
const quarantined = fsp.readdirSync('server/data/users').some(f => f.includes('.corrupt-'))
check('损坏文件已隔离保留原件', quarantined, fsp.readdirSync('server/data/users').join(','))
const d3 = JSON.parse(fsp.readFileSync('server/data/users/' + reg.json.userId + '.json', 'utf8'))
check('新纪元已写入(created_at 非空)', !!d3.created_at)
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })