Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3dc1a170 | ||
|
|
3bbc68b63e | ||
|
|
d55c768f16 | ||
|
|
5a6ab2b322 | ||
|
|
3b48a27921 | ||
|
|
793a41ab99 | ||
|
|
7d11a48756 | ||
|
|
1afc236d88 | ||
|
|
d48330e5c4 | ||
|
|
dc5ab15ae1 | ||
|
|
7a36fd4e04 | ||
|
|
0f268a495e |
@@ -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
|
||||
@@ -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 及之前版本可直接升级。
|
||||
@@ -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 安装包。
|
||||
@@ -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;网页端刷新页面即为新版。
|
||||
@@ -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`
|
||||
@@ -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 安装包
|
||||
@@ -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 网页端)
|
||||
@@ -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(网页端刷新即新版)
|
||||
- 部署后各端打开一次,核对数据(工作记录/记账等);若仍有缺,检查设置页「立即同步」的结果行并反馈
|
||||
@@ -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 安装包
|
||||
@@ -0,0 +1,13 @@
|
||||
# v0.6.8 发布说明(2026-10-26)
|
||||
|
||||
## 确认弹窗重做
|
||||
|
||||
- 修复内容贴边(上一轮组件基座统一时确认框未获得内边距,标题/正文/按钮挤作一团)
|
||||
- 重做为标准企业级危险确认:红色警告徽标 + 标题/正文分区 + 22px 内边距 + 右对齐操作区
|
||||
- 危险操作:红色三角警告徽标 + 红色标题 + 红色实心确认按钮;普通确认保持蓝白风格
|
||||
- 移动端按钮等宽 40px;Esc/点击遮罩关闭行为不变(全部 10+ 处调用点零改动)
|
||||
|
||||
## 部署
|
||||
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
|
||||
- 桌面 / 安卓:安装 0.6.8 安装包
|
||||
@@ -0,0 +1,13 @@
|
||||
# v0.6.9 发布说明(2026-10-26)
|
||||
|
||||
## 分类管理去原生弹窗,全部自绘
|
||||
|
||||
- **添加 / 修改分类**:不再使用浏览器原生 prompt,改为自绘「输入弹窗」——自动聚焦全选、回车提交、**内联校验**(空值 / 重名直接在弹窗内红字提示,不再 toast 打断)
|
||||
- **删除分类**:不再使用原生 confirm,改为统一的红色警告确认框(含保留提示:历史记录不受影响)
|
||||
- 新增通用 InputDialog 组件(输入类弹窗基座),后续项目改名等场景可复用
|
||||
- 全应用已无任何浏览器原生弹窗
|
||||
|
||||
## 部署
|
||||
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
|
||||
- 桌面 / 安卓:安装 0.6.9 安装包
|
||||
@@ -0,0 +1,12 @@
|
||||
# v0.7.0 发布说明(2026-10-26)
|
||||
|
||||
## 记账:分类筛选
|
||||
|
||||
- 工具栏新增「分类」下拉:选项为本月**实际出现的分类**(自动去重排序),支持「全部分类」
|
||||
- 与类型筛选(支出/收入)、报销筛选可叠加使用;底部「当前筛选 N 条」实时联动
|
||||
- 切换月份自动重置分类筛选,避免跨月无效选项
|
||||
|
||||
## 部署
|
||||
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
|
||||
- 桌面 / 安卓:安装 0.7.0 安装包
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Eue2aiSv.js","./index-IuJhwbTi.js","./index-MT694d7P.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-IuJhwbTi.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-Eue2aiSv.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-Cn9aGqjN.js","./index-vpp14W9X.js","./index-D9ZLjO2x.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-vpp14W9X.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-Cn9aGqjN.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
+1
File diff suppressed because one or more lines are too long
-26
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+27
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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-IuJhwbTi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-MT694d7P.css">
|
||||
<script type="module" crossorigin src="./assets/index-vpp14W9X.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D9ZLjO2x.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "intern-log",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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) })
|
||||
@@ -75,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'))
|
||||
@@ -127,7 +128,10 @@ 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)
|
||||
|
||||
|
||||
@@ -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) })
|
||||
+149
-91
@@ -10,6 +10,7 @@ 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'
|
||||
@@ -230,20 +231,31 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginView v-if="!session" @login="onLogin" />
|
||||
<div v-else class="app">
|
||||
<!-- 桌面侧栏:品牌 + 分组导航 + 当前用户 -->
|
||||
<nav class="side">
|
||||
<template v-for="(v, i) in shownViews" :key="v.key">
|
||||
<div v-if="i > 0 && v.group !== shownViews[i - 1].group" class="nav-sep"></div>
|
||||
<button
|
||||
class="nav-btn" :class="{ active: route === v.key }"
|
||||
:data-tip="v.name" @click="nav(v.key)"
|
||||
>
|
||||
<Icon :name="v.icon" :size="18" />
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
@@ -281,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>
|
||||
@@ -323,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" />
|
||||
@@ -370,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>
|
||||
@@ -377,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);
|
||||
@@ -423,40 +470,57 @@ onMounted(async () => {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 40;
|
||||
}
|
||||
.nav-btn:hover::after { opacity: 1; }
|
||||
.nav-sep {
|
||||
width: 26px;
|
||||
height: 1px;
|
||||
.side-foot {
|
||||
flex: none;
|
||||
background: var(--border);
|
||||
margin: 5px 0;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ---------- 主区 ---------- */
|
||||
.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);
|
||||
@@ -473,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;
|
||||
}
|
||||
@@ -490,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;
|
||||
@@ -504,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;
|
||||
@@ -537,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);
|
||||
@@ -550,8 +609,6 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mobile-hide { display: initial; }
|
||||
|
||||
.tabbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -565,7 +622,7 @@ onMounted(async () => {
|
||||
}
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
height: calc(52px - 6px);
|
||||
height: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -577,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;
|
||||
@@ -591,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);
|
||||
@@ -623,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;
|
||||
@@ -668,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; }
|
||||
@@ -685,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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
@@ -27,12 +28,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
<teleport to="body">
|
||||
<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-msg">{{ message }}</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button>
|
||||
<button class="btn btn-sm" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
|
||||
<div class="dialog confirm-dialog" role="dialog" :aria-label="title">
|
||||
<div class="confirm-body">
|
||||
<div v-if="danger" class="confirm-badge">
|
||||
<Icon name="warning" :size="22" />
|
||||
</div>
|
||||
<div class="confirm-text">
|
||||
<div class="confirm-title" :class="{ 'is-danger': danger }">{{ title }}</div>
|
||||
<div class="confirm-msg">{{ message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="confirm-btns">
|
||||
<button class="btn" @click="emit('cancel')">{{ cancelText }}</button>
|
||||
<button class="btn" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,29 +49,34 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(29, 33, 41, 0.4);
|
||||
.confirm-dialog {
|
||||
width: 400px;
|
||||
padding: 22px 22px 18px;
|
||||
}
|
||||
.confirm-body { display: flex; gap: 14px; align-items: flex-start; }
|
||||
.confirm-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: #FDEEEE;
|
||||
color: var(--danger);
|
||||
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-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; }
|
||||
.confirm-text { flex: 1; min-width: 0; }
|
||||
.confirm-title { font-size: 15px; font-weight: 600; color: var(--text-1); line-height: 1.4; }
|
||||
.confirm-title.is-danger { color: var(--danger); }
|
||||
.confirm-msg { margin-top: 6px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
|
||||
.confirm-btns { margin-top: 18px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.confirm-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
|
||||
.confirm-btns .btn { flex: 1; height: 40px; }
|
||||
.confirm-btns { margin-top: 16px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
initial?: string
|
||||
confirmText?: string
|
||||
validate?: (v: string) => string | null
|
||||
}>(), { label: '', placeholder: '', initial: '', confirmText: '确定' })
|
||||
|
||||
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'confirm', value: string): void }>()
|
||||
|
||||
const value = ref('')
|
||||
const err = ref('')
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!props.open) return
|
||||
if (e.key === 'Escape') emit('cancel')
|
||||
}
|
||||
watch(() => props.open, async v => {
|
||||
if (v) {
|
||||
value.value = props.initial
|
||||
err.value = ''
|
||||
window.addEventListener('keydown', onKey)
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
|
||||
function confirm() {
|
||||
const v = value.value.trim()
|
||||
if (!v) { err.value = '请输入内容'; return }
|
||||
const e = props.validate ? props.validate(v) : null
|
||||
if (e) { err.value = e; return }
|
||||
emit('confirm', v)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="open" class="mask" @click.self="emit('cancel')">
|
||||
<div class="dialog input-dialog" role="dialog" :aria-label="title">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">{{ title }}</span>
|
||||
<button class="icon-btn" title="关闭" @click="emit('cancel')">
|
||||
<Icon name="close" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="field">
|
||||
<label v-if="label">{{ label }}</label>
|
||||
<input
|
||||
ref="input" class="input" :class="{ invalid: err }"
|
||||
v-model="value" :placeholder="placeholder" maxlength="20"
|
||||
@keydown.enter="confirm"
|
||||
/>
|
||||
<div v-if="err" class="form-error">{{ err }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
|
||||
<button class="btn btn-primary btn-sm" @click="confirm">{{ confirmText }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input-dialog { width: 400px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.input-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
|
||||
.dialog-btns .btn { flex: 1; height: 40px; }
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -48,8 +48,10 @@ 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 = ref<string[]>([])
|
||||
@@ -158,57 +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;
|
||||
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 { width: 480px; }
|
||||
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
||||
.dialog-body { padding: 16px 20px; }
|
||||
.dialog-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 720px) {
|
||||
.add-cat-input { height: 34px; }
|
||||
.add-cat .btn { height: 34px; }
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -25,5 +25,6 @@ export const ICONS: Record<string, string> = {
|
||||
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"/>'),
|
||||
warning: svg('<path d="M12 4.2L21 19.2H3L12 4.2z"/><path d="M12 10.2v3.6M12 16.4h.01"/>'),
|
||||
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"/>')
|
||||
}
|
||||
|
||||
@@ -161,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
|
||||
@@ -227,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 })
|
||||
@@ -381,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 logCats" :key="c" :value="c">{{ c }}</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">
|
||||
@@ -486,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>
|
||||
@@ -500,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>
|
||||
|
||||
@@ -89,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', {
|
||||
@@ -113,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')">
|
||||
@@ -146,6 +146,13 @@ function save() {
|
||||
<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>
|
||||
@@ -173,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>
|
||||
@@ -182,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>
|
||||
|
||||
+120
-5
@@ -1,7 +1,10 @@
|
||||
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
|
||||
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
|
||||
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
|
||||
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type PlanItem, 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'
|
||||
|
||||
@@ -376,6 +379,12 @@ export async function listPlansByDate(date: string): Promise<PlanItem[]> {
|
||||
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({
|
||||
@@ -438,6 +447,108 @@ export async function stopPlanTimer(id: number): Promise<void> {
|
||||
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[]> {
|
||||
@@ -536,7 +647,8 @@ export async function exportAll(): Promise<string> {
|
||||
db.memo.toArray(),
|
||||
db.credential.toArray(),
|
||||
db.table('transaction').toArray(),
|
||||
db.plan.toArray()
|
||||
db.plan.toArray(),
|
||||
db.goal.toArray()
|
||||
])
|
||||
return JSON.stringify({
|
||||
schema_version: SCHEMA_VERSION,
|
||||
@@ -551,7 +663,8 @@ export async function exportAll(): Promise<string> {
|
||||
memo: tables[6],
|
||||
credential: tables[7],
|
||||
transaction: tables[8],
|
||||
plan: tables[9]
|
||||
plan: tables[9],
|
||||
goal: tables[10]
|
||||
}
|
||||
}, null, 2)
|
||||
}
|
||||
@@ -570,11 +683,12 @@ export async function importAll(jsonText: string): Promise<void> {
|
||||
const dl = db.table('log_item')
|
||||
const dt = db.table('transaction')
|
||||
const dp = db.plan
|
||||
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt, dp], async () => {
|
||||
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(), dp.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[])
|
||||
@@ -585,6 +699,7 @@ 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[])
|
||||
})
|
||||
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
|
||||
|
||||
@@ -48,6 +48,18 @@ 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
|
||||
@@ -59,6 +71,7 @@ export interface PlanItem {
|
||||
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
|
||||
@@ -124,6 +137,7 @@ export class WorklogDB extends Dexie {
|
||||
sync_mirror!: Table<SyncMirror, number>
|
||||
tombstone!: Table<SyncTombstone, number>
|
||||
plan!: Table<PlanItem, number>
|
||||
goal!: Table<Goal, number>
|
||||
|
||||
constructor(dbName = 'intern_worklog') {
|
||||
super(dbName)
|
||||
@@ -163,6 +177,10 @@ export class WorklogDB extends Dexie {
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +196,7 @@ export function unbindDatabase(): void {
|
||||
db = undefined
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 5
|
||||
export const SCHEMA_VERSION = 6
|
||||
|
||||
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
|
||||
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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', 'plan']
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -61,13 +61,9 @@ const incomeCats = ref<string[]>([])
|
||||
async function loadCats() {
|
||||
expenseCats.value = await getLedgerCategories('expense')
|
||||
incomeCats.value = await getLedgerCategories('income')
|
||||
if (fType.value === 'expense' && !expenseCats.value.includes(fCategory.value)) fCategory.value = expenseCats.value[0] || ''
|
||||
if (fType.value === 'income' && !incomeCats.value.includes(fCategory.value)) fCategory.value = incomeCats.value[0] || ''
|
||||
}
|
||||
loadCats()
|
||||
watch(fType, t => {
|
||||
fCategory.value = t === 'expense' ? (expenseCats.value[0] || '') : (incomeCats.value[0] || '')
|
||||
})
|
||||
|
||||
|
||||
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)))
|
||||
@@ -90,9 +86,16 @@ const summary = computed(() => {
|
||||
|
||||
const typeFilter = ref<'all' | TxType>('all')
|
||||
const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
|
||||
const catFilter = ref<string>('all')
|
||||
// 切换月份时重置分类筛选(新月份的分类集合可能不同)
|
||||
watch(monthRef, () => { catFilter.value = 'all' })
|
||||
const usedCategories = computed(() =>
|
||||
Array.from(new Set(rows.value.map(r => r.category))).sort((a, b) => a.localeCompare(b, 'zh'))
|
||||
)
|
||||
const filtered = computed(() =>
|
||||
sorted.value.filter(r => {
|
||||
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false
|
||||
if (catFilter.value !== 'all' && r.category !== catFilter.value) return false
|
||||
if (reimbFilter.value === 'pending' && !(r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1)) return false
|
||||
if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false
|
||||
return true
|
||||
@@ -192,12 +195,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)
|
||||
@@ -206,7 +221,6 @@ async function confirmDelete() {
|
||||
showToast('账目已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-page">
|
||||
<div class="ledger-toolbar">
|
||||
@@ -220,63 +234,23 @@ async function confirmDelete() {
|
||||
<option value="expense">仅支出</option>
|
||||
<option value="income">仅收入</option>
|
||||
</select>
|
||||
<select class="select w-filter" v-model="catFilter">
|
||||
<option value="all">全部分类</option>
|
||||
<option v-for="c in usedCategories" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select class="select w-filter" v-model="reimbFilter">
|
||||
<option value="all">全部报销</option>
|
||||
<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' ? expenseCats : incomeCats)" :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">
|
||||
@@ -329,11 +303,11 @@ async function confirmDelete() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="muted vault-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.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"
|
||||
@@ -356,69 +330,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,72 @@
|
||||
<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
|
||||
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem,
|
||||
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
|
||||
} from '../db/repo'
|
||||
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
|
||||
import { attendanceChanged } from '../lib/bus'
|
||||
import { 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)
|
||||
@@ -20,7 +78,16 @@ const isToday = computed(() => date.value === todayStr())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
items.value = await listPlansByDate(date.value)
|
||||
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)
|
||||
@@ -41,12 +108,13 @@ const summary = computed(() => {
|
||||
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) actualMin + elapsedMinutes(rp)
|
||||
if (rp) liveExtra = liveElapsed(rp)
|
||||
}
|
||||
return { total, done, plannedMin, actualMin }
|
||||
return { total, done, plannedMin, actualMin: actualMin + liveExtra }
|
||||
})
|
||||
function spanMinutes(p: PlanItem): number {
|
||||
const [sh, sm] = p.start_time.split(':').map(Number)
|
||||
@@ -76,6 +144,7 @@ async function toggleTimer(p: PlanItem) {
|
||||
await startPlanTimer(p.id as number)
|
||||
showToast('开始计时:' + p.title)
|
||||
}
|
||||
notifyPlanTimer()
|
||||
await load()
|
||||
}
|
||||
|
||||
@@ -144,34 +213,68 @@ function weekday(s: string): string {
|
||||
return wdNames[parseDateStr(s).getDay()]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plan-page">
|
||||
<div class="card sec plan-head">
|
||||
<div class="nav-row">
|
||||
<button class="icon-btn" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
|
||||
<div class="date-box">
|
||||
<span class="date-main num">{{ date.slice(5) }} {{ weekday(date) }}</span>
|
||||
<span class="muted date-sub num">{{ date }}</span>
|
||||
</div>
|
||||
<button class="icon-btn" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
|
||||
<button class="btn btn-sm" :class="{ 'btn-primary': !isToday }" @click="goToday">今天</button>
|
||||
<span class="flex1"></span>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
|
||||
<!-- 长期计划 -->
|
||||
<div class="blk-head">
|
||||
<div class="blk-head-text">
|
||||
<div class="blk-title">长期计划</div>
|
||||
<div class="muted blk-sub">跨越多天的目标,自动落实到每一天</div>
|
||||
</div>
|
||||
<div class="muted sum-line">
|
||||
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
|
||||
<template v-if="running"> · <span class="timer-live">计时中</span></template>
|
||||
<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="plan-list">
|
||||
<div v-if="!loading && !items.length" class="card sec empty muted">
|
||||
今天还没有计划,点右上角「添加计划」开始规划:学习、健身、自律……
|
||||
<!-- 每日安排 -->
|
||||
<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="card sec plan-item"
|
||||
class="plan-item"
|
||||
:class="{ done: p.status === 'done', skip: p.status === 'skip', running: !!p.timer_started_at }"
|
||||
>
|
||||
<div class="pi-time num">
|
||||
@@ -180,6 +283,7 @@ function weekday(s: string): string {
|
||||
<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>
|
||||
@@ -208,7 +312,7 @@ function weekday(s: string): string {
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<div v-if="dialogOpen" class="fixed-mask" @click.self="dialogOpen = false">
|
||||
<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>
|
||||
@@ -248,54 +352,129 @@ function weekday(s: string): string {
|
||||
</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; gap: 10px; max-width: 760px; }
|
||||
.plan-head { padding: 12px 16px; }
|
||||
.nav-row { display: flex; align-items: center; gap: 8px; }
|
||||
.date-box { min-width: 150px; text-align: center; }
|
||||
.date-main { font-weight: 600; font-size: 14px; }
|
||||
.date-sub { font-size: 11px; margin-left: 6px; }
|
||||
.sum-line { font-size: 12px; margin-top: 8px; }
|
||||
.timer-live { color: var(--accent); font-weight: 600; }
|
||||
.flex1 { flex: 1; }
|
||||
.plan-page { display: flex; flex-direction: column; }
|
||||
|
||||
.plan-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; }
|
||||
.plan-item.done { opacity: 0.62; }
|
||||
.plan-item.skip { opacity: 0.5; }
|
||||
.plan-item.running { border-left: 3px solid var(--accent); }
|
||||
.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, #2e9e5b); font-size: 12px; }
|
||||
.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 { text-align: center; padding: 26px 16px; }
|
||||
.empty { padding: 28px 16px; }
|
||||
|
||||
.fixed-mask {
|
||||
position: fixed; inset: 0; z-index: 80;
|
||||
background: rgba(29, 33, 41, 0.35);
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
}
|
||||
.dialog { background: var(--bg-page); border-radius: 12px; width: 420px; max-width: 100%; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
|
||||
.dialog-title { font-weight: 600; font-size: 14px; }
|
||||
.dialog-body { padding: 6px 16px 4px; }
|
||||
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 16px 16px; }
|
||||
.dialog { width: 420px; }
|
||||
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
|
||||
.form-row label { font-size: 12px; color: var(--text-2); }
|
||||
.form-row.two { flex-direction: row; gap: 10px; }
|
||||
.form-row.two > div { flex: 1; display: flex; flex-direction: column; gap: 5px; }
|
||||
.goal-hint { font-size: 12px; margin-top: 8px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.plan-item { flex-wrap: wrap; }
|
||||
.pi-ops { width: 100%; justify-content: flex-start; }
|
||||
.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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import InputDialog from '../components/InputDialog.vue'
|
||||
import {
|
||||
addProject, exportAll, getLocationPresets, getSetting, getWorkRule, importAll,
|
||||
listAttendanceBetween, listLogItemsBetween, listProjects,
|
||||
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
|
||||
} from '../db/repo'
|
||||
import { type Project } from '../db/schema'
|
||||
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 { addDays, hhmm, parseDateStr, todayStr, weekStart } from '../lib/date'
|
||||
import { hhmm, parseDateStr, todayStr } from '../lib/date'
|
||||
import { computeDay } from '../domain/attendance'
|
||||
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth'
|
||||
import { ensureCloudBinding, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
|
||||
@@ -39,74 +40,55 @@ async function loadCategories() {
|
||||
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]
|
||||
// ---- 分类编辑:自绘输入弹窗 + 危险确认框(替代浏览器原生 prompt/confirm)----
|
||||
type CatKind = 'log' | 'expense' | 'income'
|
||||
const catDialog = ref<{ open: boolean; kind: CatKind; index: number; initial: string }>({ open: false, kind: 'log', index: -1, initial: '' })
|
||||
const delCat = ref<{ kind: CatKind; index: number; name: string } | null>(null)
|
||||
|
||||
function catListOf(kind: CatKind): string[] {
|
||||
return kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
}
|
||||
async function writeCats(kind: CatKind, next: string[]) {
|
||||
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('分类已更新(历史记录自动跟随显示)')
|
||||
function openCatAdd(kind: CatKind) {
|
||||
catDialog.value = { open: true, kind, index: -1, initial: '' }
|
||||
}
|
||||
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
|
||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
function openCatRename(kind: CatKind, index: number, oldName: string) {
|
||||
catDialog.value = { open: true, kind, index, initial: oldName }
|
||||
}
|
||||
function validateCat(v: string): string | null {
|
||||
const { kind, index } = catDialog.value
|
||||
const list = catListOf(kind)
|
||||
if (list.includes(v) && !(index >= 0 && list[index] === v)) return '该分类已存在'
|
||||
return null
|
||||
}
|
||||
async function onCatDialogConfirm(v: string) {
|
||||
const { kind, index } = catDialog.value
|
||||
if (index >= 0) {
|
||||
await writeCats(kind, catListOf(kind).map((x, i) => (i === index ? v : x)))
|
||||
showToast('分类已更新(历史记录自动跟随显示)')
|
||||
} else {
|
||||
await writeCats(kind, [...catListOf(kind), v])
|
||||
showToast('分类已添加')
|
||||
}
|
||||
catDialog.value.open = false
|
||||
}
|
||||
function askRemoveCat(kind: CatKind, index: number) {
|
||||
const list = catListOf(kind)
|
||||
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) }
|
||||
delCat.value = { kind, index, name: list[index] }
|
||||
}
|
||||
async function confirmRemoveCat() {
|
||||
if (!delCat.value) return
|
||||
const { kind, index } = delCat.value
|
||||
await writeCats(kind, catListOf(kind).filter((_, i) => i !== index))
|
||||
showToast('分类已删除')
|
||||
delCat.value = null
|
||||
}
|
||||
|
||||
// ---- 工作详情范围导出 ----
|
||||
const expRange = ref<'day' | 'week' | 'month' | 'custom'>('day')
|
||||
const expStart = ref(todayStr())
|
||||
const expEnd = ref(todayStr())
|
||||
function rangeStartEnd(): { start: string; end: string; label: string } {
|
||||
const t = todayStr()
|
||||
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
|
||||
if (expRange.value === 'week') { const ws = weekStart(expStart.value); return { start: ws, end: addDays(ws, 6), label: ws + ' ~ ' + addDays(ws, 6) } }
|
||||
if (expRange.value === 'month') { const y = expStart.value.slice(0, 4); const m = expStart.value.slice(5, 7); const last = new Date(Number(y), Number(m), 0).getDate(); return { start: y + '-' + m + '-01', end: y + '-' + m + '-' + last, label: y + '-' + m } }
|
||||
return { start: expStart.value, end: expEnd.value, label: expStart.value + ' ~ ' + expEnd.value }
|
||||
}
|
||||
async function doExportRange() {
|
||||
const { start, end, label } = rangeStartEnd()
|
||||
const found = await Promise.all([listLogItemsBetween(start, end), listProjects(true)])
|
||||
const items = found[0]
|
||||
const projects = found[1]
|
||||
const pmap = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
|
||||
const rows: (string | number | null)[][] = [['日期', '分类', '项目', '内容', '耗时(分钟)', '成果', '待跟进', '收获', '备注']]
|
||||
for (const it of items) {
|
||||
rows.push([
|
||||
it.date,
|
||||
categoryLabel(it.category),
|
||||
it.project_id ? (pmap.get(it.project_id) || '') : '',
|
||||
it.content,
|
||||
it.minutes == null ? '' : it.minutes,
|
||||
it.is_milestone ? '是' : '',
|
||||
it.needs_followup ? '是' : '',
|
||||
it.takeaway || '',
|
||||
it.remark || ''
|
||||
])
|
||||
}
|
||||
const file = '工作详情_' + label.replace(/[~s]/g, '_') + '.csv'
|
||||
const p = await downloadCsv(file, rows)
|
||||
showToast(p ? '已导出:' + p : '已导出到系统下载目录(' + items.length + ' 条)')
|
||||
}
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingImport = ref<{ name: string; text: string } | null>(null)
|
||||
const importError = ref('')
|
||||
@@ -459,61 +441,37 @@ async function doImport() {
|
||||
<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-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="openCatAdd('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>
|
||||
<button class="chip-btn" title="修改" @click="openCatRename('log', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="askRemoveCat('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-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="openCatAdd('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>
|
||||
<button class="chip-btn" title="修改" @click="openCatRename('expense', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="askRemoveCat('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-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="openCatAdd('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>
|
||||
<button class="chip-btn" title="修改" @click="openCatRename('income', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="askRemoveCat('income', i)">删</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">工作详情导出</div>
|
||||
<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>
|
||||
<div class="exp-dates">
|
||||
<template v-if="expRange === 'custom'">
|
||||
<input class="input" type="date" v-model="expStart" />
|
||||
<span class="muted">至</span>
|
||||
<input class="input" type="date" v-model="expEnd" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<input class="input" type="date" v-model="expStart" />
|
||||
<span class="muted loc-note">{{ expRange === 'day' ? '导出该日' : expRange === 'week' ? '导出该日期所在一周' : '导出该日期所在月份' }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="rule-save">
|
||||
<button class="btn btn-primary btn-sm" @click="doExportRange">导出 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">数据备份</div>
|
||||
<div class="backup-row">
|
||||
@@ -530,6 +488,27 @@ async function doImport() {
|
||||
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
|
||||
</div>
|
||||
|
||||
<InputDialog
|
||||
:open="catDialog.open"
|
||||
:title="catDialog.index >= 0 ? '修改分类名称' : '添加分类'"
|
||||
label="分类名称"
|
||||
placeholder="如:制度学习 / 差旅交通"
|
||||
:initial="catDialog.initial"
|
||||
:validate="validateCat"
|
||||
confirm-text="保存"
|
||||
@cancel="catDialog.open = false"
|
||||
@confirm="onCatDialogConfirm"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!delCat"
|
||||
title="删除分类"
|
||||
:message="delCat ? '将删除分类「' + delCat.name + '」。历史记录不受影响,仍按原分类展示。' : ''"
|
||||
confirm-text="删除" danger
|
||||
@cancel="delCat = null"
|
||||
@confirm="confirmRemoveCat"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!pendingImport"
|
||||
title="导入确认"
|
||||
@@ -553,18 +532,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; }
|
||||
@@ -572,22 +549,19 @@ async function doImport() {
|
||||
|
||||
.loc-note { font-size: 12px; }
|
||||
.ai-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
.backup-row { display: flex; gap: 8px; }
|
||||
.cat-block { margin-top: 10px; }
|
||||
.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); }
|
||||
.exp-dates { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.exp-dates .input { width: 150px; }
|
||||
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 5px 12px; }
|
||||
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
|
||||
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
.backup-note { font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -180,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
+10
-4
@@ -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,13 @@ 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:
|
||||
|
||||
@@ -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) })
|
||||
+159
-32
@@ -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', 'plan']
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
|
||||
|
||||
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
|
||||
const NATURAL_KEYS = {
|
||||
@@ -35,7 +40,13 @@ 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))
|
||||
}
|
||||
|
||||
@@ -126,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 })
|
||||
@@ -266,7 +346,15 @@ async function handle(req, res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); 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') {
|
||||
@@ -355,10 +443,13 @@ async function handle(req, res) {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
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 || {}
|
||||
@@ -379,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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,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] || {}
|
||||
@@ -428,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')
|
||||
|
||||
// 返回权威全量
|
||||
@@ -442,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
|
||||
}
|
||||
|
||||
@@ -508,27 +608,53 @@ 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()
|
||||
}
|
||||
@@ -538,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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) })
|
||||
Reference in New Issue
Block a user