Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
971853560a | ||
|
|
d300fd9b96 | ||
|
|
1e3dc1a170 | ||
|
|
3bbc68b63e | ||
|
|
d55c768f16 | ||
|
|
5a6ab2b322 | ||
|
|
3b48a27921 | ||
|
|
793a41ab99 | ||
|
|
7d11a48756 | ||
|
|
1afc236d88 | ||
|
|
d48330e5c4 |
@@ -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 安装包
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# v0.7.1 记账周期统计
|
||||||
|
|
||||||
|
## 新增
|
||||||
|
- 记账支持按统计周期查看:按月 / 按期 / 自定义三种模式,统计卡、支出分类、表格与筛选全部跟随所选周期。
|
||||||
|
- 按期模式:设置「账期起点」(如实习开始日 10-14)后自动切分第 1、2、3…期,第 N 期 = 起点 + (N-1) 个月,到起点 + N 个月的前一天(如 10.14 开启,第 1 期为 10.14 – 11.13)。
|
||||||
|
- 账期起点存于设置表,随账号多端同步;设置起点后自动切换为按期视图。
|
||||||
|
- 起点之前如有账目,期列表中提供「起点前」入口,避免数据「不见」的疑惑。
|
||||||
|
- 自定义模式:任意起止日期区间(如 10.14 – 10.31)。
|
||||||
|
- 新增「统计周期」选择弹窗:月份列表(仅有账目的月份)、期列表(带日期范围)、自定义区间三个页签。
|
||||||
|
|
||||||
|
## 优化
|
||||||
|
- 工具栏月份标题改为可点击的周期选择按钮,‹ › 按当前模式逐周期移动。
|
||||||
|
- 「回到本月」按钮仅在偏离当前周期时出现,减少常驻按钮。
|
||||||
|
- 自定义模式下隐藏前后翻页箭头(无操作对象)。
|
||||||
|
- 清除页面遗留的快速记账表单死代码(约 90 行未使用的脚本)。
|
||||||
|
- 文案「本月」统一为「本期」(统计卡、分类、空态、计数)。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- 纯客户端功能,无 schema 变更、无服务端改动;部署后刷新网页即生效。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# v0.7.2 顶栏精简
|
||||||
|
|
||||||
|
## 优化
|
||||||
|
- 移除右上角常驻的「上班打卡」按钮:打卡在「详情」页当日面板与日历当日抽屉中操作,顶栏不再重复。
|
||||||
|
- 移除右上角常驻的「填写本周周记」提示按钮:改为周五至周日打开应用时 toast 提示一次,不占用常驻界面;设置页「周记提醒」开关继续有效。
|
||||||
|
- 顶栏仅保留页面标题与搜索,移动端搜索按钮右对齐。
|
||||||
|
- 清除相关死代码约 80 行(打卡三态、周记提示样式)。
|
||||||
-27
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-BuQ7VOet.js","./index-B3bd8iWX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Deo5OtIz.js","./index-ynPmf8i8.js","./index-COwenkhO.css"])))=>i.map(i=>d[i]);
|
||||||
import{r as s,_ as i}from"./index-B3bd8iWX.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-BuQ7VOet.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
import{r as s,_ as i}from"./index-ynPmf8i8.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-Deo5OtIz.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
-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="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||||
<title>实习工作日志</title>
|
<title>实习工作日志</title>
|
||||||
<script type="module" crossorigin src="./assets/index-B3bd8iWX.js"></script>
|
<script type="module" crossorigin src="./assets/index-ynPmf8i8.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-COwenkhO.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "intern-log",
|
"name": "intern-log",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.1",
|
"version": "0.7.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ async function main() {
|
|||||||
check('LWW 反向: 旧 work_start 被墓碑', tombsOf(lwwR2).some(t => t.table === 'setting' && t.uuid === 'su1'))
|
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)
|
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 === 'mt1')))
|
||||||
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
|
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
|
||||||
check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1'))
|
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')
|
const aRec = regJson.users.find(u => u.username === 'audit_a')
|
||||||
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
|
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
|
||||||
await new Promise(r => globalThis.setTimeout(r, 400))
|
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(() => ({}))
|
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
|
||||||
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
|
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) })
|
||||||
+33
-99
@@ -15,11 +15,11 @@ import UserAdminView from './views/UserAdminView.vue'
|
|||||||
import LoginView from './views/LoginView.vue'
|
import LoginView from './views/LoginView.vue'
|
||||||
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
|
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
|
||||||
import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } from './sync/engine'
|
import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } from './sync/engine'
|
||||||
import { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
|
import { getWorkRule, getWeeklyNote, searchLogItems, searchMemos } from './db/repo'
|
||||||
import { addDays, hhmm, todayStr, weekStart } from './lib/date'
|
import { todayStr, weekStart } from './lib/date'
|
||||||
import { showToast, useToasts } from './lib/toast'
|
import { showToast, useToasts } from './lib/toast'
|
||||||
import { attendanceChanged, focusDate, focusMemo, notifyAttendanceChanged } from './lib/bus'
|
import { focusDate, focusMemo } from './lib/bus'
|
||||||
import { categoryLabel, type Attendance, type LogItem, type Memo } from './db/schema'
|
import { categoryLabel, type LogItem, type Memo } from './db/schema'
|
||||||
|
|
||||||
const toasts = useToasts()
|
const toasts = useToasts()
|
||||||
|
|
||||||
@@ -30,8 +30,7 @@ async function onLogin(s: Session) {
|
|||||||
openUserDatabase(s.userId)
|
openUserDatabase(s.userId)
|
||||||
location.hash = '#/calendar'
|
location.hash = '#/calendar'
|
||||||
route.value = 'calendar'
|
route.value = 'calendar'
|
||||||
await refreshClock()
|
await checkWeeklyReminder()
|
||||||
await refreshTip()
|
|
||||||
startSyncTimer()
|
startSyncTimer()
|
||||||
try {
|
try {
|
||||||
await ensureCloudBinding()
|
await ensureCloudBinding()
|
||||||
@@ -161,72 +160,24 @@ function pickLog(item: LogItem & { date: string }) { openLog(item); mobileSearch
|
|||||||
function pickMemo(m: Memo) { openMemo(m); mobileSearch.value = false }
|
function pickMemo(m: Memo) { openMemo(m); mobileSearch.value = false }
|
||||||
|
|
||||||
|
|
||||||
// ---- 工具栏打卡 ----
|
// ---- 周记提醒:周五至周日打开应用时 toast 提示一次,不占用常驻界面 ----
|
||||||
const today = ref(todayStr())
|
async function checkWeeklyReminder() {
|
||||||
const att = ref<Attendance | undefined>(undefined)
|
try {
|
||||||
const yesterdayPending = ref(false)
|
const rule = await getWorkRule()
|
||||||
|
if (rule.remind_weekly === 'off') return
|
||||||
async function refreshClock() {
|
|
||||||
att.value = await getAttendance(today.value)
|
|
||||||
yesterdayPending.value = false
|
|
||||||
const now = new Date()
|
|
||||||
if (now.getHours() < 4) {
|
|
||||||
const yRec = await getAttendance(addDays(today.value, -1))
|
|
||||||
yesterdayPending.value = !!(yRec && yRec.clock_in_at && !yRec.clock_out_at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActiveSession = computed(() => {
|
|
||||||
const a = att.value
|
|
||||||
if (a && a.clock_in_at && !a.clock_out_at) return true
|
|
||||||
return yesterdayPending.value && !(a && a.clock_in_at)
|
|
||||||
})
|
|
||||||
const clockDone = computed(() => {
|
|
||||||
const a = att.value
|
|
||||||
return !!(a && a.clock_in_at && a.clock_out_at)
|
|
||||||
})
|
|
||||||
const clockLabel = computed(() => {
|
|
||||||
const a = att.value
|
|
||||||
if (hasActiveSession.value) return '下班打卡'
|
|
||||||
if (clockDone.value) return '已打卡 ' + hhmm(a?.clock_in_at) + ' - ' + hhmm(a?.clock_out_at)
|
|
||||||
return '上班打卡'
|
|
||||||
})
|
|
||||||
|
|
||||||
async function doClock() {
|
|
||||||
if (hasActiveSession.value) {
|
|
||||||
try { await smartClockOut() } catch { /* 没有进行中的打卡 */ }
|
|
||||||
} else {
|
|
||||||
await clockIn(today.value)
|
|
||||||
}
|
|
||||||
await refreshClock()
|
|
||||||
notifyAttendanceChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任何视图发生的考勤变更(含今日面板、日历抽屉)都实时同步到顶栏
|
|
||||||
watch(attendanceChanged, () => {
|
|
||||||
if (session.value) refreshClock()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- 周记提醒 ----
|
|
||||||
const weekTip = ref(false)
|
|
||||||
async function refreshTip() {
|
|
||||||
const rule: WorkRule = await getWorkRule()
|
|
||||||
if (rule.remind_weekly === 'off') { weekTip.value = false; return }
|
|
||||||
const wd = new Date().getDay()
|
const wd = new Date().getDay()
|
||||||
if (wd === 5 || wd === 6 || wd === 0) {
|
if (wd !== 5 && wd !== 6 && wd !== 0) return
|
||||||
const note = await getWeeklyNote(weekStart(today.value))
|
const note = await getWeeklyNote(weekStart(todayStr()))
|
||||||
weekTip.value = !note || (!note.highlights && !note.challenges && !note.plan_next)
|
if (note && (note.highlights || note.challenges || note.plan_next)) return
|
||||||
} else {
|
showToast('本周周记还没写,可在「总结」页填写')
|
||||||
weekTip.value = false
|
} catch { /* 提醒检查失败不影响使用 */ }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', onGlobalKey)
|
window.addEventListener('keydown', onGlobalKey)
|
||||||
if (session.value) {
|
if (session.value) {
|
||||||
openUserDatabase(session.value.userId)
|
openUserDatabase(session.value.userId)
|
||||||
await refreshClock()
|
await checkWeeklyReminder()
|
||||||
await refreshTip()
|
|
||||||
startSyncTimer()
|
startSyncTimer()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -287,17 +238,7 @@ onMounted(async () => {
|
|||||||
<div v-else class="gs-empty muted">无匹配结果</div>
|
<div v-else class="gs-empty muted">无匹配结果</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="flex1"></span>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<button v-if="weekTip" class="week-tip" @click="nav('report')">
|
|
||||||
<span class="dot" style="background: var(--warn)"></span>
|
|
||||||
填写本周周记
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" :disabled="clockDone" @click="doClock">
|
|
||||||
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
|
|
||||||
<span class="num">{{ clockLabel }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<div class="content-inner">
|
<div class="content-inner">
|
||||||
@@ -333,6 +274,7 @@ onMounted(async () => {
|
|||||||
<!-- 手机端「更多」底部弹层 -->
|
<!-- 手机端「更多」底部弹层 -->
|
||||||
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
|
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
|
||||||
<div class="more-sheet" @click.stop>
|
<div class="more-sheet" @click.stop>
|
||||||
|
<div class="sheet-handle"></div>
|
||||||
<div class="sheet-user num">{{ session?.username }}</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)">
|
<button v-for="v in moreViews" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
|
||||||
<Icon :name="v.icon" :size="18" />
|
<Icon :name="v.icon" :size="18" />
|
||||||
@@ -452,6 +394,7 @@ onMounted(async () => {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
.nav-label { white-space: nowrap; }
|
.nav-label { white-space: nowrap; }
|
||||||
|
/* tooltip 仅在折叠态(图标-only)显示;宽侧栏文字可见,不需要 */
|
||||||
.nav-btn::after {
|
.nav-btn::after {
|
||||||
content: attr(data-tip);
|
content: attr(data-tip);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -505,8 +448,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
|
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
|
||||||
|
|
||||||
.topbar-actions { display: flex; align-items: center; gap: 8px; }
|
|
||||||
|
|
||||||
.gsearch { position: relative; width: 320px; max-width: 38vw; }
|
.gsearch { position: relative; width: 320px; max-width: 38vw; }
|
||||||
.gs-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
|
.gs-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
|
||||||
.gs-input {
|
.gs-input {
|
||||||
@@ -552,21 +493,6 @@ onMounted(async () => {
|
|||||||
.gs-date { font-size: 12px; flex: none; }
|
.gs-date { font-size: 12px; flex: none; }
|
||||||
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
|
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
|
||||||
|
|
||||||
.week-tip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
border: 1px solid var(--warn);
|
|
||||||
color: var(--warn);
|
|
||||||
background: var(--bg-page);
|
|
||||||
border-radius: 999px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.week-tip:hover { background: #FDF4EC; }
|
|
||||||
|
|
||||||
.content { flex: 1; overflow: auto; }
|
.content { flex: 1; overflow: auto; }
|
||||||
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
|
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
|
||||||
@@ -647,10 +573,18 @@ onMounted(async () => {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
padding: 10px 14px calc(14px + env(safe-area-inset-bottom));
|
padding: 6px 14px calc(14px + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
margin: 4px auto 8px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
.sheet-user {
|
.sheet-user {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -721,6 +655,7 @@ onMounted(async () => {
|
|||||||
.brand-name, .nav-label, .nav-group { display: none; }
|
.brand-name, .nav-label, .nav-group { display: none; }
|
||||||
.nav-btn { justify-content: center; padding: 0; width: 40px; align-self: center; }
|
.nav-btn { justify-content: center; padding: 0; width: 40px; align-self: center; }
|
||||||
.nav-btn.active::before { display: none; }
|
.nav-btn.active::before { display: none; }
|
||||||
|
.nav-btn:hover::after { opacity: 1; }
|
||||||
.side-foot { flex-direction: column; gap: 6px; }
|
.side-foot { flex-direction: column; gap: 6px; }
|
||||||
.foot-name { display: none; }
|
.foot-name { display: none; }
|
||||||
}
|
}
|
||||||
@@ -741,16 +676,15 @@ onMounted(async () => {
|
|||||||
.gsearch { display: none; }
|
.gsearch { display: none; }
|
||||||
.gsearch .gs-panel { display: none; }
|
.gsearch .gs-panel { display: none; }
|
||||||
.mob-search-btn { display: flex; }
|
.mob-search-btn { display: flex; }
|
||||||
.topbar-actions { margin-left: auto; gap: 6px; }
|
.mob-search-btn { margin-left: auto; }
|
||||||
.week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; }
|
|
||||||
.topbar-actions .btn { padding: 0 10px; height: 32px; }
|
|
||||||
.topbar-actions .btn .num { font-size: 12px; }
|
|
||||||
|
|
||||||
.tabbar { display: flex; }
|
.tabbar { display: flex; }
|
||||||
|
.tab-btn { height: 56px; font-size: 11px; }
|
||||||
|
.tab-btn .icon { width: 22px; height: 22px; }
|
||||||
.content { flex: 1; min-height: 0; }
|
.content { flex: 1; min-height: 0; }
|
||||||
.content-inner {
|
.content-inner {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
padding: 12px 12px calc(70px + env(safe-area-inset-bottom));
|
padding: 12px 12px calc(78px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-wrap {
|
.toast-wrap {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onUnmounted, watch } from 'vue'
|
import { onUnmounted, watch } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -27,12 +28,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
|||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<transition name="modal">
|
<transition name="modal">
|
||||||
<div v-if="open" class="mask" @click.self="emit('cancel')">
|
<div v-if="open" class="mask" @click.self="emit('cancel')">
|
||||||
<div class="dialog" role="dialog" :aria-label="title">
|
<div class="dialog confirm-dialog" role="dialog" :aria-label="title">
|
||||||
<div class="dialog-title">{{ title }}</div>
|
<div class="confirm-body">
|
||||||
<div class="dialog-msg">{{ message }}</div>
|
<div v-if="danger" class="confirm-badge">
|
||||||
<div class="dialog-btns">
|
<Icon name="warning" :size="22" />
|
||||||
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button>
|
</div>
|
||||||
<button class="btn btn-sm" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +49,34 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dialog { width: 380px; }
|
.confirm-dialog {
|
||||||
.dialog-msg { margin-top: 8px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
|
width: 400px;
|
||||||
.dialog-btns { margin-top: 20px; }
|
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;
|
||||||
|
}
|
||||||
|
.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>
|
</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,207 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { addDays, todayStr } from '../lib/date'
|
||||||
|
import { cycleStartOf, cycleEndOf, fmtDayRef, fmtRange } from '../lib/period'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
mode: 'month' | 'cycle' | 'custom'
|
||||||
|
anchor: string
|
||||||
|
month: string
|
||||||
|
cycleSel: number | 'before'
|
||||||
|
customStart: string
|
||||||
|
customEnd: string
|
||||||
|
cycleCount: number
|
||||||
|
hasBefore: boolean
|
||||||
|
months: string[]
|
||||||
|
}>(), { anchor: '', months: () => [] })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'apply', payload: { mode: 'month' | 'cycle' | 'custom'; month?: string; cycle?: number | 'before'; customStart?: string; customEnd?: string }): void
|
||||||
|
(e: 'saveAnchor', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dMode = ref(props.mode)
|
||||||
|
const dMonth = ref(props.month.slice(0, 7))
|
||||||
|
const dCycle = ref<number | 'before'>(props.cycleSel)
|
||||||
|
const dStart = ref(props.customStart)
|
||||||
|
const dEnd = ref(props.customEnd)
|
||||||
|
const dAnchor = ref(props.anchor)
|
||||||
|
const customErr = ref('')
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
watch(() => props.open, v => {
|
||||||
|
if (v) {
|
||||||
|
dMode.value = props.mode
|
||||||
|
dMonth.value = props.month.slice(0, 7)
|
||||||
|
dCycle.value = props.cycleSel
|
||||||
|
dStart.value = props.customStart
|
||||||
|
dEnd.value = props.customEnd
|
||||||
|
dAnchor.value = props.anchor
|
||||||
|
customErr.value = ''
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(() => [props.anchor, props.cycleSel] as const, () => {
|
||||||
|
dAnchor.value = props.anchor
|
||||||
|
dCycle.value = props.cycleSel
|
||||||
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||||
|
|
||||||
|
// 有账目的月份 ∪ 当前选中月 ∪ 本月,倒序
|
||||||
|
const monthOptions = computed(() => {
|
||||||
|
const set = new Set(props.months)
|
||||||
|
set.add(dMonth.value)
|
||||||
|
set.add(todayStr().slice(0, 7))
|
||||||
|
return Array.from(set).sort((a, b) => (a < b ? 1 : -1))
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeEndLabel = computed(() =>
|
||||||
|
props.anchor ? fmtDayRef(addDays(props.anchor, -1), props.anchor) : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
function monthLabel(m: string): string {
|
||||||
|
return m.slice(0, 4) + ' 年 ' + Number(m.slice(5, 7)) + ' 月'
|
||||||
|
}
|
||||||
|
function pickMonth(m: string) {
|
||||||
|
dMonth.value = m
|
||||||
|
emit('apply', { mode: 'month', month: m })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
function pickCycle(c: number | 'before') {
|
||||||
|
dCycle.value = c
|
||||||
|
emit('apply', { mode: 'cycle', cycle: c })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
function saveAnchor() {
|
||||||
|
if (!dAnchor.value) return
|
||||||
|
emit('saveAnchor', dAnchor.value)
|
||||||
|
}
|
||||||
|
function applyCustom() {
|
||||||
|
if (!dStart.value || !dEnd.value) { customErr.value = '请选择开始和结束日期'; return }
|
||||||
|
if (dStart.value > dEnd.value) { customErr.value = '开始日期不能晚于结束日期'; return }
|
||||||
|
customErr.value = ''
|
||||||
|
emit('apply', { mode: 'custom', customStart: dStart.value, customEnd: dEnd.value })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<transition name="modal">
|
||||||
|
<div v-if="open" class="mask" @click.self="emit('close')">
|
||||||
|
<div class="dialog period-dialog" role="dialog" aria-label="统计周期">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<span class="dialog-title">统计周期</span>
|
||||||
|
<button class="icon-btn" title="关闭" @click="emit('close')">
|
||||||
|
<Icon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="seg seg-row">
|
||||||
|
<button class="seg-btn" :class="{ active: dMode === 'month' }" @click="dMode = 'month'">按月</button>
|
||||||
|
<button class="seg-btn" :class="{ active: dMode === 'cycle' }" @click="dMode = 'cycle'">按期</button>
|
||||||
|
<button class="seg-btn" :class="{ active: dMode === 'custom' }" @click="dMode = 'custom'">自定义</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按月:有账目的月份列表 -->
|
||||||
|
<div v-if="dMode === 'month'" class="pane">
|
||||||
|
<button
|
||||||
|
v-for="m in monthOptions" :key="m"
|
||||||
|
class="opt" :class="{ active: m === dMonth }"
|
||||||
|
@click="pickMonth(m)"
|
||||||
|
>
|
||||||
|
<span>{{ monthLabel(m) }}</span>
|
||||||
|
<Icon v-if="m === dMonth" name="check-circle" :size="15" />
|
||||||
|
</button>
|
||||||
|
<div v-if="!monthOptions.length" class="hint">还没有账目记录</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按期:账期起点 + 期列表 -->
|
||||||
|
<div v-else-if="dMode === 'cycle'" class="pane">
|
||||||
|
<div class="anchor-row">
|
||||||
|
<span class="anchor-label">账期起点</span>
|
||||||
|
<input class="input" type="date" v-model="dAnchor" />
|
||||||
|
<button class="btn btn-sm" :disabled="!dAnchor || dAnchor === anchor" @click="saveAnchor">保存</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!dAnchor" class="hint">填写实习开始日期并保存,自动切分为第 1、2、3…期(每期到下月同日前一天)</div>
|
||||||
|
<div v-else-if="dAnchor !== anchor" class="hint">起点已修改,保存后按新起点重新切分</div>
|
||||||
|
<template v-if="anchor">
|
||||||
|
<button
|
||||||
|
v-if="hasBefore" class="opt" :class="{ active: dCycle === 'before' }"
|
||||||
|
@click="pickCycle('before')"
|
||||||
|
>
|
||||||
|
<span>起点前</span>
|
||||||
|
<span class="muted num">~ {{ beforeEndLabel }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="n in cycleCount" :key="n"
|
||||||
|
class="opt" :class="{ active: dCycle === n }"
|
||||||
|
@click="pickCycle(n)"
|
||||||
|
>
|
||||||
|
<span>第 {{ n }} 期</span>
|
||||||
|
<span class="muted num">{{ fmtRange(cycleStartOf(anchor, n), cycleEndOf(anchor, n)) }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义:任意起止区间 -->
|
||||||
|
<div v-else class="pane">
|
||||||
|
<div class="custom-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>开始日期</label>
|
||||||
|
<input class="input" type="date" v-model="dStart" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>结束日期</label>
|
||||||
|
<input class="input" type="date" v-model="dEnd" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="customErr" class="form-error">{{ customErr }}</div>
|
||||||
|
<button class="btn btn-primary apply-btn" @click="applyCustom">应用区间</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-btns">
|
||||||
|
<button class="btn btn-sm" @click="emit('close')">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.period-dialog { width: 400px; }
|
||||||
|
.seg-row { display: flex; width: 100%; }
|
||||||
|
.seg-row .seg-btn { flex: 1; }
|
||||||
|
.pane { margin-top: 12px; max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.opt {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
border: none; background: transparent; cursor: pointer;
|
||||||
|
padding: 8px 10px; border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px; color: var(--text-1); text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.opt:hover { background: var(--bg-hover); }
|
||||||
|
.opt.active { background: rgba(51, 112, 255, 0.08); color: var(--accent); font-weight: 600; }
|
||||||
|
.opt.active .muted { color: var(--accent); opacity: 0.75; }
|
||||||
|
.anchor-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.anchor-label { font-size: 12px; color: var(--text-2); flex: none; }
|
||||||
|
.anchor-row .input { flex: 1; min-width: 0; }
|
||||||
|
.hint { font-size: 12px; color: var(--text-3); padding: 4px 2px; }
|
||||||
|
.custom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.apply-btn { width: 100%; margin-top: 6px; }
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||||
|
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.period-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
|
||||||
|
.dialog-btns .btn { flex: 1; height: 40px; }
|
||||||
|
.pane { max-height: 48vh; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -147,7 +147,7 @@ onUnmounted(() => {
|
|||||||
.tp {
|
.tp {
|
||||||
left: 12px;
|
left: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: calc(66px + env(safe-area-inset-bottom));
|
bottom: calc(64px + env(safe-area-inset-bottom));
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,4 +163,8 @@ function save() {
|
|||||||
.dialog { width: 480px; }
|
.dialog { width: 480px; }
|
||||||
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
||||||
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.add-cat-input { height: 34px; }
|
||||||
|
.add-cat .btn { height: 34px; }
|
||||||
|
}
|
||||||
</style>
|
</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"/>'),
|
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"/>'),
|
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
|
||||||
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
|
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
|
||||||
|
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"/>')
|
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"/>')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ async function confirmDelete() {
|
|||||||
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
|
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
|
||||||
.att-cell 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; }
|
.att-val { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 13px; }
|
||||||
|
.att-val > .num { font-size: 14px; font-weight: 600; letter-spacing: 0.2px; }
|
||||||
.val-empty { color: var(--text-3); }
|
.val-empty { color: var(--text-3); }
|
||||||
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
.time-input { width: 104px; }
|
.time-input { width: 104px; }
|
||||||
@@ -444,7 +445,7 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.item:hover { border-color: var(--border-strong); background: var(--bg-page); }
|
.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-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-meta { font-size: 12px; }
|
||||||
.item-sub {
|
.item-sub {
|
||||||
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
|
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
|
||||||
@@ -452,7 +453,15 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
|
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 720px) {
|
||||||
.att-grid { grid-template-columns: 1fr; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ function save() {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>耗时(分钟)</label>
|
<label>耗时(分钟)</label>
|
||||||
<input class="input" type="number" min="0" v-model.number="formMinutes" placeholder="选填" />
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>主要收获</label>
|
<label>主要收获</label>
|
||||||
@@ -183,4 +190,18 @@ function save() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dialog { width: 560px; }
|
.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: 999px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.min-chip:hover { background: var(--bg-hover); }
|
||||||
|
.min-chip.on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -634,6 +634,19 @@ export async function setTransactionReimbursed(id: number, done: boolean): Promi
|
|||||||
await commit()
|
await commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 有账目的月份列表(YYYY-MM,倒序),供周期选择弹窗使用
|
||||||
|
export async function listTransactionMonths(): Promise<string[]> {
|
||||||
|
const all = await db.table('transaction').toArray()
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const r of all) if (r && r.date) set.add(r.date.slice(0, 7))
|
||||||
|
return Array.from(set).sort((a, b) => (a < b ? 1 : -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某日期之前的账目条数(判断「起点前」是否有数据)
|
||||||
|
export async function countTransactionsBefore(date: string): Promise<number> {
|
||||||
|
return db.table('transaction').where('date').below(date).count()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 备份 ----------
|
// ---------- 备份 ----------
|
||||||
|
|
||||||
export async function exportAll(): Promise<string> {
|
export async function exportAll(): Promise<string> {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// 账期(统计周期)推算:全部基于 YYYY-MM-DD 字符串与整数运算,不涉及时区。
|
||||||
|
// 第 n 期 = [起点 + (n-1) 个月, 起点 + n 个月 - 1 天];月加法沿用 date.ts 的自然月末收敛
|
||||||
|
//(如起点 01-31,下一期从 02-28 起)。
|
||||||
|
import { addDays, addMonths } from './date'
|
||||||
|
|
||||||
|
export type CycleSel = number | 'before'
|
||||||
|
|
||||||
|
// 第 n 期的开始日期(n 从 1 开始)
|
||||||
|
export function cycleStartOf(anchor: string, n: number): string {
|
||||||
|
return addMonths(anchor, n - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 n 期的结束日期
|
||||||
|
export function cycleEndOf(anchor: string, n: number): string {
|
||||||
|
return addDays(addMonths(anchor, n), -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某日期落在第几期;早于起点返回 0(即「起点前」)
|
||||||
|
export function cycleIndexOf(anchor: string, date: string): number {
|
||||||
|
if (!anchor || date < anchor) return 0
|
||||||
|
let n = (Number(date.slice(0, 4)) - Number(anchor.slice(0, 4))) * 12
|
||||||
|
+ (Number(date.slice(5, 7)) - Number(anchor.slice(5, 7))) + 1
|
||||||
|
if (n < 1) n = 1
|
||||||
|
if (n > 1 && date < cycleStartOf(anchor, n)) n--
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10.14 形式的短日期
|
||||||
|
export function fmtDay(d: string): string {
|
||||||
|
return Number(d.slice(5, 7)) + '.' + Number(d.slice(8, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与参照日期同年则省略年份,跨年则带完整年月日
|
||||||
|
export function fmtDayRef(d: string, ref: string): string {
|
||||||
|
return d.slice(0, 4) === ref.slice(0, 4) ? fmtDay(d) : d.split('-').join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「10.14 – 11.13」,跨年时两侧均带年份
|
||||||
|
export function fmtRange(start: string, end: string): string {
|
||||||
|
return fmtDayRef(start, end) + ' – ' + fmtDayRef(end, start)
|
||||||
|
}
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
手机端(<=720px)全局适配:作用于各视图与弹窗组件
|
手机端(<=720px)全局适配
|
||||||
(App.vue 的壳层样式是 scoped,跨组件规则统一放这里,便于集中管理)
|
原则:触控目标 ≥30px(按钮 32-36 / 输入 38 / 弹窗主按钮 40)、
|
||||||
|
板块两列优先避免过度堆叠、内容优先密度适中
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@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; }
|
.card { border-radius: 12px; }
|
||||||
|
.section, .sec { padding: 12px; }
|
||||||
|
.stat-num { font-size: 20px; }
|
||||||
|
.empty { padding: 22px 12px; }
|
||||||
|
|
||||||
input.input, select.select, textarea.textarea {
|
input.input, select.select, textarea.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -13,20 +42,7 @@
|
|||||||
}
|
}
|
||||||
.btn { touch-action: manipulation; }
|
.btn { touch-action: manipulation; }
|
||||||
|
|
||||||
/* ---------- 弹窗:窄屏近全屏 ---------- */
|
/* ---------- 日历当日抽屉:底部抽屉 ---------- */
|
||||||
.dialog {
|
|
||||||
width: calc(100vw - 20px) !important;
|
|
||||||
max-width: calc(100vw - 20px) !important;
|
|
||||||
max-height: 88vh;
|
|
||||||
max-height: 88dvh;
|
|
||||||
}
|
|
||||||
.dialog-head { padding: 14px 16px 10px !important; }
|
|
||||||
.dialog-body { padding: 14px 16px !important; }
|
|
||||||
.dialog-btns { padding: 10px 16px 14px !important; }
|
|
||||||
.dialog-btns .btn { flex: 1; }
|
|
||||||
.form-grid { grid-template-columns: 1fr !important; }
|
|
||||||
|
|
||||||
/* ---------- 日历当日抽屉:改为底部抽屉 ---------- */
|
|
||||||
.drawer {
|
.drawer {
|
||||||
top: auto !important;
|
top: auto !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
@@ -42,7 +58,7 @@
|
|||||||
.drawer-head { border-radius: 14px 14px 0 0; }
|
.drawer-head { border-radius: 14px 14px 0 0; }
|
||||||
.drawer-body { padding: 12px !important; }
|
.drawer-body { padding: 12px !important; }
|
||||||
|
|
||||||
/* ---------- 记账表格:卡片行(带行内标签),手机上不横滚 ---------- */
|
/* ---------- 记账表格:卡片行(带行内标签) ---------- */
|
||||||
.tx-table { min-width: 0 !important; }
|
.tx-table { min-width: 0 !important; }
|
||||||
.tx-table thead { display: none !important; }
|
.tx-table thead { display: none !important; }
|
||||||
.tx-table tr { display: block; padding: 10px 12px; border-bottom: 1px solid var(--border); background: transparent; }
|
.tx-table tr { display: block; padding: 10px 12px; border-bottom: 1px solid var(--border); background: transparent; }
|
||||||
@@ -63,11 +79,12 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.tx-table td.ops-cell::before { content: 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 { justify-content: center; }
|
||||||
.tx-table td.empty::before { content: none !important; }
|
.tx-table td.empty::before { content: none !important; }
|
||||||
|
|
||||||
/* ---------- 密码库等其余宽表:横向滚动 ---------- */
|
/* ---------- 密码库等宽表:横滚 + 工具栏堆叠 ---------- */
|
||||||
.table-card, .tx-card { overflow-x: auto; }
|
.table-card, .tx-card { overflow-x: auto; }
|
||||||
.cred-table { min-width: 560px !important; }
|
.cred-table { min-width: 560px !important; }
|
||||||
.th-ops { width: auto !important; }
|
.th-ops { width: auto !important; }
|
||||||
@@ -79,15 +96,13 @@
|
|||||||
/* ---------- 统计卡两列 ---------- */
|
/* ---------- 统计卡两列 ---------- */
|
||||||
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 10px !important; }
|
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 10px !important; }
|
||||||
.stat-card { padding: 12px 14px !important; }
|
.stat-card { padding: 12px 14px !important; }
|
||||||
.stat-num { font-size: 19px !important; }
|
|
||||||
|
|
||||||
/* ---------- 分区与栅格收窄 ---------- */
|
/* ---------- 分区与栅格 ---------- */
|
||||||
.section, .sec { padding: 12px 12px !important; }
|
|
||||||
.rule-grid { grid-template-columns: 1fr !important; }
|
.rule-grid { grid-template-columns: 1fr !important; }
|
||||||
.two-col { grid-template-columns: 1fr !important; }
|
.two-col { grid-template-columns: 1fr !important; }
|
||||||
|
|
||||||
/* ---------- 日历格子收窄 ---------- */
|
/* ---------- 日历格子 ---------- */
|
||||||
.cal-toolbar { margin-bottom: 8px; }
|
.cal-toolbar { margin-bottom: 10px; }
|
||||||
.cal-cell { min-height: 54px !important; padding: 4px 5px !important; }
|
.cal-cell { min-height: 58px !important; padding: 5px 6px !important; }
|
||||||
.cal-marks { margin-top: 3px !important; height: 12px !important; gap: 3px !important; }
|
.cal-marks { margin-top: 4px !important; height: 14px !important; gap: 4px !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
|||||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
|
.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:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; }
|
.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: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 { color: var(--danger); border-color: var(--danger); background: var(--bg-page); }
|
||||||
.btn-danger:hover { background: #FDF0F0; }
|
.btn-danger:hover { background: #FDF0F0; }
|
||||||
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
|
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
|
||||||
@@ -279,6 +281,16 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
|||||||
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
|
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
|
||||||
.bar-num { font-size: 12px; flex: none; color: var(--text-3); }
|
.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 { display: inline-flex; align-items: center; justify-content: center; flex: none; }
|
||||||
.icon svg { width: 100%; height: 100%; display: block; }
|
.icon svg { width: 100%; height: 100%; display: block; }
|
||||||
|
|||||||
@@ -159,14 +159,15 @@ export async function syncWithStoredConfig(): Promise<SyncResult> {
|
|||||||
return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password })
|
return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存(失败会把缓存回退到服务器真值)
|
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存
|
||||||
|
// 注意:绝不向界面抛出——写操作本身已在本地提交成功,同步失败只记录状态
|
||||||
|
// (设置页可见 + 下一轮自动同步续传),否则一次服务端故障会把每次点击都变成全屏报错
|
||||||
export async function flushAfterWrite(): Promise<void> {
|
export async function flushAfterWrite(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await syncWithStoredConfig()
|
await syncWithStoredConfig()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
syncLastError.value = e instanceof Error ? e.message : String(e)
|
syncLastError.value = e instanceof Error ? e.message : String(e)
|
||||||
try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ }
|
try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ }
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +374,16 @@ async function doRunSync(config: SyncConfig, epochRetry = false): Promise<SyncRe
|
|||||||
const serverTables: Record<string, AnyRow[]> = data.tables || {}
|
const serverTables: Record<string, AnyRow[]> = data.tables || {}
|
||||||
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
|
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. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
|
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
|
||||||
const applyResult = await applySnapshot(serverTables, serverTombstones)
|
const applyResult = await applySnapshot(serverTables, serverTombstones)
|
||||||
|
|
||||||
@@ -427,7 +438,9 @@ async function applySnapshot(
|
|||||||
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
|
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 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 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))
|
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ watch(focusDate, d => {
|
|||||||
<button
|
<button
|
||||||
v-for="(c, idx) in cells" :key="idx"
|
v-for="(c, idx) in cells" :key="idx"
|
||||||
class="cal-cell"
|
class="cal-cell"
|
||||||
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date }"
|
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date, 'is-today': c.date === t }"
|
||||||
:title="c.inMonth ? cellTitle(c.date) : undefined"
|
:title="c.inMonth ? cellTitle(c.date) : undefined"
|
||||||
@click="selectDay(c.date, c.inMonth)"
|
@click="selectDay(c.date, c.inMonth)"
|
||||||
>
|
>
|
||||||
@@ -149,6 +149,7 @@ watch(focusDate, d => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.cal-cell {
|
.cal-cell {
|
||||||
@@ -163,7 +164,8 @@ watch(focusDate, d => {
|
|||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
}
|
}
|
||||||
.cal-cell:hover { background: var(--bg-subtle); }
|
.cal-cell:hover { background: var(--bg-subtle); }
|
||||||
.cal-cell.blank { cursor: default; }
|
.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); }
|
.cal-cell.selected { background: var(--accent-bg); box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
.daynum.today { color: var(--accent); font-weight: 600; }
|
.daynum.today { color: var(--accent); font-weight: 600; }
|
||||||
.cal-cell.weekend .daynum { color: var(--text-3); }
|
.cal-cell.weekend .daynum { color: var(--text-3); }
|
||||||
@@ -200,9 +202,7 @@ watch(focusDate, d => {
|
|||||||
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
|
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.cal-cell { min-height: 60px; padding: 4px 5px; }
|
|
||||||
.cal-title { min-width: 76px; font-size: 13px; }
|
.cal-title { min-width: 76px; font-size: 13px; }
|
||||||
.cal-marks { gap: 3px; margin-top: 4px; height: 12px; }
|
|
||||||
.drawer { width: 100%; }
|
.drawer { width: 100%; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,49 +3,130 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import TransactionDialog from '../components/TransactionDialog.vue'
|
import TransactionDialog from '../components/TransactionDialog.vue'
|
||||||
|
import PeriodDialog from '../components/PeriodDialog.vue'
|
||||||
import {
|
import {
|
||||||
addTransaction, deleteTransaction, listTransactionsBetween,
|
addTransaction, countTransactionsBefore, deleteTransaction, getSetting,
|
||||||
|
listTransactionMonths, listTransactionsBetween, setSetting,
|
||||||
setTransactionReimbursed, updateTransaction, type Transaction
|
setTransactionReimbursed, updateTransaction, type Transaction
|
||||||
} from '../db/repo'
|
} from '../db/repo'
|
||||||
import { getLedgerCategories } from '../db/categories'
|
import { getLedgerCategories } from '../db/categories'
|
||||||
import { addMonths, todayStr } from '../lib/date'
|
import { addDays, addMonths, todayStr } from '../lib/date'
|
||||||
|
import { cycleEndOf, cycleIndexOf, cycleStartOf, fmtDayRef, fmtRange, type CycleSel } from '../lib/period'
|
||||||
import { showToast } from '../lib/toast'
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
type TxType = 'expense' | 'income'
|
type TxType = 'expense' | 'income'
|
||||||
|
type PeriodMode = 'month' | 'cycle' | 'custom'
|
||||||
|
|
||||||
|
// 周期偏好为设备本地习惯;账期起点存 setting 表,随账号多端同步
|
||||||
|
const LS_MODE = 'dsh_ledger_mode'
|
||||||
|
const LS_CYCLE = 'dsh_ledger_cycle'
|
||||||
|
const LS_CSTART = 'dsh_ledger_cstart'
|
||||||
|
const LS_CEND = 'dsh_ledger_cend'
|
||||||
|
const EARLIEST = '0000-01-01'
|
||||||
|
|
||||||
|
const mode = ref<PeriodMode>('month')
|
||||||
const monthRef = ref(todayStr())
|
const monthRef = ref(todayStr())
|
||||||
|
const cycleSel = ref<CycleSel>(1)
|
||||||
|
const customStart = ref(todayStr().slice(0, 7) + '-01')
|
||||||
|
const customEnd = ref(todayStr())
|
||||||
|
const anchor = ref('')
|
||||||
|
const hasBefore = ref(false)
|
||||||
|
const txMonths = ref<string[]>([])
|
||||||
|
|
||||||
const rows = ref<Transaction[]>([])
|
const rows = ref<Transaction[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
// ---- 新增表单 ----
|
|
||||||
const fType = ref<TxType>('expense')
|
|
||||||
const fAmount = ref('')
|
|
||||||
const fCategory = ref('')
|
|
||||||
const fDate = ref(todayStr())
|
|
||||||
const fNote = ref('')
|
|
||||||
const fReimb = ref(false)
|
|
||||||
const fError = ref('')
|
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const submitting = ref(false)
|
|
||||||
|
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
||||||
const deleteTarget = ref<Transaction | null>(null)
|
const deleteTarget = ref<Transaction | null>(null)
|
||||||
|
const periodOpen = ref(false)
|
||||||
|
|
||||||
const monthStart = computed(() => monthRef.value.slice(0, 7) + '-01')
|
// ---- 统计周期 ----
|
||||||
const monthEnd = computed(() => {
|
|
||||||
const y = Number(monthRef.value.slice(0, 4))
|
function monthEndOf(ym: string): string {
|
||||||
const m0 = Number(monthRef.value.slice(5, 7)) - 1
|
const y = Number(ym.slice(0, 4))
|
||||||
|
const m0 = Number(ym.slice(5, 7)) - 1
|
||||||
const last = new Date(y, m0 + 1, 0).getDate()
|
const last = new Date(y, m0 + 1, 0).getDate()
|
||||||
return monthRef.value.slice(0, 7) + '-' + (last < 10 ? '0' + last : String(last))
|
return ym + '-' + (last < 10 ? '0' + last : String(last))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今天所在的期数(未设起点时按 1 处理)
|
||||||
|
const currentCycle = computed(() => (anchor.value ? Math.max(1, cycleIndexOf(anchor.value, todayStr())) : 1))
|
||||||
|
|
||||||
|
const rangeStart = computed(() => {
|
||||||
|
if (mode.value === 'month') return monthRef.value.slice(0, 7) + '-01'
|
||||||
|
if (mode.value === 'custom') return customStart.value
|
||||||
|
if (!anchor.value) return monthRef.value.slice(0, 7) + '-01' // 未设起点时按月兜底
|
||||||
|
return cycleSel.value === 'before' ? EARLIEST : cycleStartOf(anchor.value, cycleSel.value)
|
||||||
})
|
})
|
||||||
const title = computed(() => Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月')
|
const rangeEnd = computed(() => {
|
||||||
|
if (mode.value === 'month') return monthEndOf(monthRef.value.slice(0, 7))
|
||||||
|
if (mode.value === 'custom') return customEnd.value
|
||||||
|
if (!anchor.value) return monthEndOf(monthRef.value.slice(0, 7))
|
||||||
|
return cycleSel.value === 'before' ? addDays(anchor.value, -1) : cycleEndOf(anchor.value, cycleSel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (mode.value === 'month') return Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月'
|
||||||
|
if (mode.value === 'custom') return fmtRange(rangeStart.value, rangeEnd.value)
|
||||||
|
if (!anchor.value) return '按期统计 · 设置起点'
|
||||||
|
if (cycleSel.value === 'before') return '起点前 · ~ ' + fmtDayRef(addDays(anchor.value, -1), anchor.value)
|
||||||
|
return '第 ' + cycleSel.value + ' 期 · ' + fmtRange(rangeStart.value, rangeEnd.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canPrev = computed(() => {
|
||||||
|
if (mode.value === 'month') return true
|
||||||
|
if (mode.value === 'cycle' && anchor.value) {
|
||||||
|
if (cycleSel.value === 'before') return false
|
||||||
|
return cycleSel.value > 1 || hasBefore.value
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (mode.value === 'month') return true
|
||||||
|
if (mode.value === 'cycle' && anchor.value) {
|
||||||
|
return cycleSel.value === 'before' ? true : cycleSel.value < currentCycle.value
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
function step(dir: -1 | 1) {
|
||||||
|
if (mode.value === 'month') {
|
||||||
|
monthRef.value = addMonths(monthRef.value, dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode.value !== 'cycle' || !anchor.value) return
|
||||||
|
if (cycleSel.value === 'before') {
|
||||||
|
if (dir === 1) cycleSel.value = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const n = Number(cycleSel.value) + dir
|
||||||
|
if (n > currentCycle.value) return
|
||||||
|
if (n < 1) {
|
||||||
|
if (hasBefore.value) cycleSel.value = 'before'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cycleSel.value = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有偏离当前周期时才出现「回到本期」,平时不占工具栏
|
||||||
|
const offCurrent = computed(() => {
|
||||||
|
if (mode.value === 'month') return monthRef.value.slice(0, 7) !== todayStr().slice(0, 7)
|
||||||
|
if (mode.value === 'cycle') return !!anchor.value && cycleSel.value !== currentCycle.value
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
function goCurrent() {
|
||||||
|
if (mode.value === 'cycle') cycleSel.value = currentCycle.value
|
||||||
|
else monthRef.value = todayStr()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 数据加载:全部统计共用 [rangeStart, rangeEnd] ----
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
rows.value = await listTransactionsBetween(monthStart.value, monthEnd.value)
|
rows.value = await listTransactionsBetween(rangeStart.value, rangeEnd.value)
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = '账目加载失败:' + (e instanceof Error ? (e.stack || e.message) : String(e))
|
loadError.value = '账目加载失败:' + (e instanceof Error ? (e.stack || e.message) : String(e))
|
||||||
@@ -53,18 +134,62 @@ async function load() {
|
|||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
onMounted(load)
|
|
||||||
watch(monthRef, load)
|
onMounted(async () => {
|
||||||
|
const savedMode = localStorage.getItem(LS_MODE)
|
||||||
|
if (savedMode === 'month' || savedMode === 'cycle' || savedMode === 'custom') mode.value = savedMode
|
||||||
|
const cs = localStorage.getItem(LS_CSTART)
|
||||||
|
const ce = localStorage.getItem(LS_CEND)
|
||||||
|
if (cs && ce && cs <= ce) { customStart.value = cs; customEnd.value = ce }
|
||||||
|
const cyc = localStorage.getItem(LS_CYCLE)
|
||||||
|
if (cyc === 'before') cycleSel.value = 'before'
|
||||||
|
else if (cyc && Number(cyc) >= 1) cycleSel.value = Number(cyc)
|
||||||
|
anchor.value = await getSetting('ledger.anchor', '')
|
||||||
|
if (cycleSel.value !== 'before' && cycleSel.value > currentCycle.value) cycleSel.value = currentCycle.value
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
watch([rangeStart, rangeEnd], () => {
|
||||||
|
catFilter.value = 'all' // 切换周期时重置分类筛选(分类集合可能不同)
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
watch(mode, v => localStorage.setItem(LS_MODE, v))
|
||||||
|
watch(cycleSel, v => localStorage.setItem(LS_CYCLE, String(v)))
|
||||||
|
watch([customStart, customEnd], ([s, e]) => {
|
||||||
|
localStorage.setItem(LS_CSTART, s)
|
||||||
|
localStorage.setItem(LS_CEND, e)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openPeriodDialog() {
|
||||||
|
try {
|
||||||
|
txMonths.value = await listTransactionMonths()
|
||||||
|
hasBefore.value = anchor.value ? (await countTransactionsBefore(anchor.value)) > 0 : false
|
||||||
|
} catch { /* 统计失败不阻塞打开弹窗 */ }
|
||||||
|
periodOpen.value = true
|
||||||
|
}
|
||||||
|
function onApply(p: { mode: PeriodMode; month?: string; cycle?: number | 'before'; customStart?: string; customEnd?: string }) {
|
||||||
|
mode.value = p.mode
|
||||||
|
if (p.month) monthRef.value = p.month + '-01'
|
||||||
|
if (p.cycle !== undefined) cycleSel.value = p.cycle
|
||||||
|
if (p.customStart) customStart.value = p.customStart
|
||||||
|
if (p.customEnd) customEnd.value = p.customEnd
|
||||||
|
}
|
||||||
|
async function onSaveAnchor(v: string) {
|
||||||
|
anchor.value = v
|
||||||
|
await setSetting('ledger.anchor', v)
|
||||||
|
mode.value = 'cycle'
|
||||||
|
cycleSel.value = Math.max(1, cycleIndexOf(v, todayStr()))
|
||||||
|
try {
|
||||||
|
hasBefore.value = (await countTransactionsBefore(v)) > 0
|
||||||
|
} catch { /* 忽略统计失败 */ }
|
||||||
|
showToast('账期起点已保存,已按新起点切分期数')
|
||||||
|
}
|
||||||
|
|
||||||
const expenseCats = ref<string[]>([])
|
const expenseCats = ref<string[]>([])
|
||||||
const incomeCats = ref<string[]>([])
|
|
||||||
async function loadCats() {
|
async function loadCats() {
|
||||||
expenseCats.value = await getLedgerCategories('expense')
|
expenseCats.value = await getLedgerCategories('expense')
|
||||||
incomeCats.value = await getLedgerCategories('income')
|
|
||||||
}
|
}
|
||||||
loadCats()
|
loadCats()
|
||||||
|
|
||||||
|
|
||||||
const sorted = computed(() =>
|
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)))
|
rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1)))
|
||||||
)
|
)
|
||||||
@@ -86,9 +211,14 @@ const summary = computed(() => {
|
|||||||
|
|
||||||
const typeFilter = ref<'all' | TxType>('all')
|
const typeFilter = ref<'all' | TxType>('all')
|
||||||
const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
|
const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
|
||||||
|
const catFilter = ref<string>('all')
|
||||||
|
const usedCategories = computed(() =>
|
||||||
|
Array.from(new Set(rows.value.map(r => r.category))).sort((a, b) => a.localeCompare(b, 'zh'))
|
||||||
|
)
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
sorted.value.filter(r => {
|
sorted.value.filter(r => {
|
||||||
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false
|
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 === 'pending' && !(r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1)) return false
|
||||||
if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false
|
if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false
|
||||||
return true
|
return true
|
||||||
@@ -116,59 +246,6 @@ function amountText(r: Transaction): string {
|
|||||||
return (r.type === 'income' ? '+' : '-') + yuan(r.amount)
|
return (r.type === 'income' ? '+' : '-') + yuan(r.amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevMonth() { monthRef.value = addMonths(monthRef.value, -1) }
|
|
||||||
function nextMonth() { monthRef.value = addMonths(monthRef.value, 1) }
|
|
||||||
function goThisMonth() { monthRef.value = todayStr() }
|
|
||||||
|
|
||||||
watch(fAmount, () => {
|
|
||||||
if (fError.value) fError.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const amountText = String(fAmount.value ?? '').trim()
|
|
||||||
const v = Number(amountText)
|
|
||||||
if (!amountText || !isFinite(v) || v <= 0) {
|
|
||||||
fError.value = '请填写大于 0 的金额'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!fDate.value) {
|
|
||||||
fError.value = '请选择日期'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (submitting.value) return
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await addTransaction({
|
|
||||||
type: fType.value,
|
|
||||||
category: fCategory.value,
|
|
||||||
amount: Math.round(v * 100),
|
|
||||||
date: fDate.value,
|
|
||||||
note: String(fNote.value ?? '').trim(),
|
|
||||||
reimbursable: fType.value === 'expense' && fReimb.value ? 1 : 0,
|
|
||||||
reimbursed: 0
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
showToast('添加失败:' + (e instanceof Error ? e.message : String(e)))
|
|
||||||
submitting.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fAmount.value = ''
|
|
||||||
fNote.value = ''
|
|
||||||
fReimb.value = false
|
|
||||||
fError.value = ''
|
|
||||||
await load()
|
|
||||||
showToast('记账已添加')
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
const formDirty = computed(() => String(fAmount.value ?? '').trim() !== '' || String(fNote.value ?? '').trim() !== '')
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
fAmount.value = ''
|
|
||||||
fNote.value = ''
|
|
||||||
fReimb.value = false
|
|
||||||
fError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markReimbursed(row: Transaction) {
|
async function markReimbursed(row: Transaction) {
|
||||||
await setTransactionReimbursed(row.id as number, true)
|
await setTransactionReimbursed(row.id as number, true)
|
||||||
await load()
|
await load()
|
||||||
@@ -214,19 +291,31 @@ async function confirmDelete() {
|
|||||||
showToast('账目已删除')
|
showToast('账目已删除')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ledger-page">
|
<div class="ledger-page">
|
||||||
<div class="ledger-toolbar">
|
<div class="ledger-toolbar">
|
||||||
<button class="icon-btn" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
|
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canPrev" title="上一周期" @click="step(-1)">
|
||||||
<span class="month-title num">{{ title }}</span>
|
<Icon name="chevron-left" :size="16" />
|
||||||
<button class="icon-btn" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
|
</button>
|
||||||
<button class="btn btn-sm" @click="goThisMonth">回到本月</button>
|
<button class="period-title" title="选择统计周期" @click="openPeriodDialog">
|
||||||
|
<span class="num">{{ title }}</span>
|
||||||
|
<Icon name="chevron-down" :size="14" />
|
||||||
|
</button>
|
||||||
|
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canNext" title="下一周期" @click="step(1)">
|
||||||
|
<Icon name="chevron-right" :size="16" />
|
||||||
|
</button>
|
||||||
|
<button v-if="offCurrent" class="btn btn-sm" @click="goCurrent">{{ mode === 'cycle' ? '回到本期' : '回到本月' }}</button>
|
||||||
<span class="flex1"></span>
|
<span class="flex1"></span>
|
||||||
<select class="select w-filter" v-model="typeFilter">
|
<select class="select w-filter" v-model="typeFilter">
|
||||||
<option value="all">全部类型</option>
|
<option value="all">全部类型</option>
|
||||||
<option value="expense">仅支出</option>
|
<option value="expense">仅支出</option>
|
||||||
<option value="income">仅收入</option>
|
<option value="income">仅收入</option>
|
||||||
</select>
|
</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">
|
<select class="select w-filter" v-model="reimbFilter">
|
||||||
<option value="all">全部报销</option>
|
<option value="all">全部报销</option>
|
||||||
<option value="pending">待报销</option>
|
<option value="pending">待报销</option>
|
||||||
@@ -236,14 +325,14 @@ async function confirmDelete() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cards">
|
<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 stat-income"><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 stat-expense"><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 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"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</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>
|
||||||
|
|
||||||
<div class="card sec" v-if="categoryStats.length">
|
<div class="card sec" v-if="categoryStats.length">
|
||||||
<div class="sec-title">本月支出分类</div>
|
<div class="sec-title">本期支出分类</div>
|
||||||
<div v-for="c in categoryStats" :key="c.name" class="bar-row">
|
<div v-for="c in categoryStats" :key="c.name" class="bar-row">
|
||||||
<span class="bar-label">{{ c.name }}</span>
|
<span class="bar-label">{{ c.name }}</span>
|
||||||
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%' }"></span></span>
|
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%' }"></span></span>
|
||||||
@@ -286,13 +375,13 @@ async function confirmDelete() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!filtered.length && !loading">
|
<tr v-if="!filtered.length && !loading">
|
||||||
<td colspan="6" class="empty muted">本月暂无账目</td>
|
<td colspan="6" class="empty muted">该周期暂无账目</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="muted table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
|
<div class="muted table-note num">本期 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
|
||||||
|
|
||||||
<TransactionDialog
|
<TransactionDialog
|
||||||
:open="dialogOpen"
|
:open="dialogOpen"
|
||||||
@@ -302,6 +391,22 @@ async function confirmDelete() {
|
|||||||
@save="onDialogSave"
|
@save="onDialogSave"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PeriodDialog
|
||||||
|
:open="periodOpen"
|
||||||
|
:mode="mode"
|
||||||
|
:anchor="anchor"
|
||||||
|
:month="monthRef"
|
||||||
|
:cycle-sel="cycleSel"
|
||||||
|
:custom-start="customStart"
|
||||||
|
:custom-end="customEnd"
|
||||||
|
:cycle-count="currentCycle"
|
||||||
|
:has-before="hasBefore"
|
||||||
|
:months="txMonths"
|
||||||
|
@close="periodOpen = false"
|
||||||
|
@apply="onApply"
|
||||||
|
@save-anchor="onSaveAnchor"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="!!deleteTarget"
|
:open="!!deleteTarget"
|
||||||
title="删除确认"
|
title="删除确认"
|
||||||
@@ -315,10 +420,23 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ledger-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
.ledger-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
.month-title { font-size: 14px; font-weight: 600; min-width: 88px; text-align: center; }
|
.period-title {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
|
||||||
|
border: none; background: transparent; cursor: pointer;
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text-1);
|
||||||
|
padding: 5px 10px; border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.period-title:hover { background: var(--bg-hover); }
|
||||||
.w-filter { width: 110px; flex: none; }
|
.w-filter { width: 110px; flex: none; }
|
||||||
|
.icon-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
.card.sec, .card.section { margin-top: 12px; }
|
.card.sec, .card.section { margin-top: 12px; }
|
||||||
.bar-label { width: 56px; }
|
.bar-label { width: 56px; }
|
||||||
@@ -332,7 +450,11 @@ async function confirmDelete() {
|
|||||||
.table-card { overflow-x: auto; }
|
.table-card { overflow-x: auto; }
|
||||||
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
|
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 720px) {
|
||||||
.ledger-toolbar .w-filter { flex: 1; min-width: 0; width: auto; }
|
.ledger-toolbar { gap: 8px; }
|
||||||
|
.ledger-toolbar .flex1 { display: none; }
|
||||||
|
.period-title { flex: 1; justify-content: flex-start; padding-left: 0; }
|
||||||
|
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
|
||||||
|
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,12 +78,13 @@ async function submit() {
|
|||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
background: var(--bg-app);
|
background: linear-gradient(180deg, #EBF0F8 0%, var(--bg-app) 46%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
.login-card .input { height: 38px; }
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -248,8 +248,11 @@ function fmtTime(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.memo-page { grid-template-columns: 1fr; }
|
.memo-page { grid-template-columns: 1fr; gap: 10px; }
|
||||||
.memo-list { max-height: 240px; }
|
.memo-list { max-height: 210px; }
|
||||||
.content-textarea { min-height: 240px; }
|
.content-textarea { min-height: 260px; }
|
||||||
|
.title-input { height: 38px; }
|
||||||
|
.editor-foot { flex-wrap: wrap; }
|
||||||
|
.editor-foot .btn { flex: 1; min-height: 34px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -236,7 +236,10 @@ function weekday(s: string): string {
|
|||||||
<div class="goal-bar">
|
<div class="goal-bar">
|
||||||
<span
|
<span
|
||||||
class="goal-bar-fill"
|
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) + '%' }"
|
: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>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted num goal-meta">
|
<div class="muted num goal-meta">
|
||||||
@@ -459,8 +462,19 @@ function weekday(s: string): string {
|
|||||||
.goal-hint { font-size: 12px; margin-top: 8px; }
|
.goal-hint { font-size: 12px; margin-top: 8px; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.plan-item { flex-wrap: wrap; }
|
.blk-head { margin: 0 0 10px; }
|
||||||
.pi-ops { width: 100%; justify-content: flex-start; }
|
.day-head { margin-top: 16px; }
|
||||||
.day-ops { width: 100%; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ function undoPolish() {
|
|||||||
.md-editor { min-height: 420px; line-height: 1.7; }
|
.md-editor { min-height: 420px; line-height: 1.7; }
|
||||||
|
|
||||||
.sum-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; }
|
.sum-row { display: flex; justify-content: space-between; padding: 3px 0; 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); }
|
.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-item { font-size: 12px; padding: 2px 0; word-break: break-all; }
|
||||||
.sum-empty { font-size: 12px; }
|
.sum-empty { font-size: 12px; }
|
||||||
@@ -228,4 +229,11 @@ function undoPolish() {
|
|||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.report-grid { grid-template-columns: 1fr; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
|
import InputDialog from '../components/InputDialog.vue'
|
||||||
import {
|
import {
|
||||||
addProject, exportAll, getLocationPresets, getSetting, getWorkRule, importAll,
|
addProject, exportAll, getLocationPresets, getSetting, getWorkRule, importAll,
|
||||||
listAttendanceBetween, listLogItemsBetween, listProjects,
|
listAttendanceBetween, listLogItemsBetween, listProjects,
|
||||||
@@ -39,37 +40,53 @@ async function loadCategories() {
|
|||||||
expenseCats.value = await getLedgerCategories('expense')
|
expenseCats.value = await getLedgerCategories('expense')
|
||||||
incomeCats.value = await getLedgerCategories('income')
|
incomeCats.value = await getLedgerCategories('income')
|
||||||
}
|
}
|
||||||
async function addCat(kind: 'log' | 'expense' | 'income') {
|
// ---- 分类编辑:自绘输入弹窗 + 危险确认框(替代浏览器原生 prompt/confirm)----
|
||||||
const name = window.prompt('新分类名称')
|
type CatKind = 'log' | 'expense' | 'income'
|
||||||
if (!name || !name.trim()) return
|
const catDialog = ref<{ open: boolean; kind: CatKind; index: number; initial: string }>({ open: false, kind: 'log', index: -1, initial: '' })
|
||||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
const delCat = ref<{ kind: CatKind; index: number; name: string } | null>(null)
|
||||||
const clean = name.trim()
|
|
||||||
if (list.includes(clean)) { showToast('分类已存在'); return }
|
function catListOf(kind: CatKind): string[] {
|
||||||
const next = [...list, clean]
|
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) }
|
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||||||
|
}
|
||||||
|
function openCatAdd(kind: CatKind) {
|
||||||
|
catDialog.value = { open: true, kind, index: -1, initial: '' }
|
||||||
|
}
|
||||||
|
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('分类已添加')
|
showToast('分类已添加')
|
||||||
}
|
}
|
||||||
async function renameCat(kind: 'log' | 'expense' | 'income', index: number, oldName: string) {
|
catDialog.value.open = false
|
||||||
const name = window.prompt('修改分类名称', oldName)
|
|
||||||
if (!name || !name.trim() || name.trim() === oldName) return
|
|
||||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
|
||||||
const next = list.map((x, i) => (i === index ? name.trim() : x))
|
|
||||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
|
||||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
|
||||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
|
||||||
showToast('分类已更新(历史记录自动跟随显示)')
|
|
||||||
}
|
}
|
||||||
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
|
function askRemoveCat(kind: CatKind, index: number) {
|
||||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
const list = catListOf(kind)
|
||||||
if (list.length <= 1) { showToast('至少保留一个分类'); return }
|
if (list.length <= 1) { showToast('至少保留一个分类'); return }
|
||||||
if (!window.confirm('删除分类「' + list[index] + '」?历史记录不受影响。')) return
|
delCat.value = { kind, index, name: list[index] }
|
||||||
const next = list.filter((_, i) => i !== index)
|
}
|
||||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
async function confirmRemoveCat() {
|
||||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
if (!delCat.value) return
|
||||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
const { kind, index } = delCat.value
|
||||||
|
await writeCats(kind, catListOf(kind).filter((_, i) => i !== index))
|
||||||
showToast('分类已删除')
|
showToast('分类已删除')
|
||||||
|
delCat.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
@@ -424,32 +441,32 @@ async function doImport() {
|
|||||||
<div class="card section">
|
<div class="card section">
|
||||||
<div class="section-title">工作分类</div>
|
<div class="section-title">工作分类</div>
|
||||||
<div class="cat-block">
|
<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">
|
<div class="cat-chips">
|
||||||
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
|
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
|
||||||
{{ c }}
|
{{ c }}
|
||||||
<button class="chip-btn" title="修改" @click="renameCat('log', i, c)">改</button>
|
<button class="chip-btn" title="修改" @click="openCatRename('log', i, c)">改</button>
|
||||||
<button class="chip-btn" title="删除" @click="removeCat('log', i)">删</button>
|
<button class="chip-btn" title="删除" @click="askRemoveCat('log', i)">删</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cat-block">
|
<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">
|
<div class="cat-chips">
|
||||||
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
|
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
|
||||||
{{ c }}
|
{{ c }}
|
||||||
<button class="chip-btn" title="修改" @click="renameCat('expense', i, c)">改</button>
|
<button class="chip-btn" title="修改" @click="openCatRename('expense', i, c)">改</button>
|
||||||
<button class="chip-btn" title="删除" @click="removeCat('expense', i)">删</button>
|
<button class="chip-btn" title="删除" @click="askRemoveCat('expense', i)">删</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cat-block">
|
<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">
|
<div class="cat-chips">
|
||||||
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
|
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
|
||||||
{{ c }}
|
{{ c }}
|
||||||
<button class="chip-btn" title="修改" @click="renameCat('income', i, c)">改</button>
|
<button class="chip-btn" title="修改" @click="openCatRename('income', i, c)">改</button>
|
||||||
<button class="chip-btn" title="删除" @click="removeCat('income', i)">删</button>
|
<button class="chip-btn" title="删除" @click="askRemoveCat('income', i)">删</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,6 +488,27 @@ async function doImport() {
|
|||||||
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
|
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
|
||||||
</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
|
<ConfirmDialog
|
||||||
:open="!!pendingImport"
|
:open="!!pendingImport"
|
||||||
title="导入确认"
|
title="导入确认"
|
||||||
|
|||||||
@@ -138,8 +138,13 @@ watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.v
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.tool-sep { display: none; }
|
.tool-sep { display: none; }
|
||||||
.nav-row { flex: 1; }
|
.toolbar { padding: 10px 12px; gap: 10px; }
|
||||||
|
.nav-row { flex: 1 1 100%; }
|
||||||
.date-title { min-width: 0; flex: 1; text-align: left; }
|
.date-title { min-width: 0; flex: 1; text-align: left; }
|
||||||
.w-date { flex: 1; min-width: 120px; width: auto; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ async function doChangePwd() {
|
|||||||
.td-system { font-weight: 500; }
|
.td-system { font-weight: 500; }
|
||||||
.th-ops { width: 150px; }
|
.th-ops { width: 150px; }
|
||||||
.pwd-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
.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 { cursor: pointer; }
|
||||||
.copyable:hover { color: var(--accent); }
|
.copyable:hover { color: var(--accent); }
|
||||||
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
|
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
|||||||
Binary file not shown.
+7
-2
@@ -62,8 +62,13 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
|
|||||||
|
|
||||||
- 数据目录:/opt/daywork/server/data
|
- 数据目录:/opt/daywork/server/data
|
||||||
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
|
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
|
||||||
- users/<id>.json 每用户九张表数据 + 墓碑
|
- users/<id>.json 每用户全部业务表数据 + 墓碑
|
||||||
- backups/<日期>/ **每日自动本地备份**(启动与每次注册表变更也会触发,保留最近 14 天)
|
- backups/<日期>/ 每日快照。**当日首份不可变**(完整证据链),同日再备份写入 `<日期>_t<时分秒>` 副本
|
||||||
|
- backups/weekly/<日期>/ 每周一额外深快照,保留 8 份
|
||||||
|
- 保留策略:每日快照 14 天;写入均为「临时文件 + fsync + 原子改名」,断电不会留下半截文件
|
||||||
|
- 用户数据文件若解析失败会被自动隔离为 `<文件>.corrupt-<时间戳>` 并在日志中大声报错,绝不静默当作空数据
|
||||||
|
- 墓碑自动回收:删除标记保留 14 天后自动清除(足够覆盖离线设备),文件不会无限膨胀
|
||||||
|
- 单次同步携带 ≥20 条墓碑(大批删除)时,服务端会**先自动快照再应用删除**,可随时回滚
|
||||||
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
|
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
|
||||||
|
|
||||||
environment:
|
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) })
|
||||||
+147
-23
@@ -11,6 +11,11 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
|
|||||||
const PORT = Number(process.env.PORT || 3780)
|
const PORT = Number(process.env.PORT || 3780)
|
||||||
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
|
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
|
||||||
const ITER = 150000
|
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', 'goal', 'transaction', 'plan']
|
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
|
||||||
|
|
||||||
@@ -35,7 +40,13 @@ function readJson(file, fallback) {
|
|||||||
}
|
}
|
||||||
function writeJson(file, data) {
|
function writeJson(file, data) {
|
||||||
const tmp = path.join(DATA_DIR, file + '.tmp')
|
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))
|
fs.renameSync(tmp, path.join(DATA_DIR, file))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,11 +137,77 @@ function isAdminToken(token) {
|
|||||||
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
|
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
|
||||||
|
|
||||||
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
|
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) {
|
function loadUserData(userId) {
|
||||||
const data = readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
|
const full = userFile(userId)
|
||||||
// 数据纪元:用户数据文件首次创建时间。目录被误删重建后该值改变,客户端据此识别并全量重推自救
|
if (fs.existsSync(full)) {
|
||||||
if (!data.created_at) data.created_at = new Date().toISOString()
|
try {
|
||||||
return data
|
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) {
|
function saveUserData(userId, data) {
|
||||||
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
|
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
|
||||||
@@ -269,7 +346,15 @@ async function handle(req, res) {
|
|||||||
return
|
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 无法自定义请求头)
|
// SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头)
|
||||||
if (req.method === 'GET' && route === '/api/events') {
|
if (req.method === 'GET' && route === '/api/events') {
|
||||||
@@ -358,10 +443,13 @@ async function handle(req, res) {
|
|||||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||||
const body = await readBody(req)
|
const body = await readBody(req)
|
||||||
const userData = loadUserData(auth.userId)
|
const loaded = loadUserData(auth.userId)
|
||||||
if (!userData.cursor) userData.cursor = 0
|
const userData = loaded.data
|
||||||
if (!userData.tables) userData.tables = {}
|
let dirty = loaded.healed
|
||||||
if (!userData.tombstones) userData.tombstones = {}
|
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 || {}
|
const incomingTables = body.tables || {}
|
||||||
@@ -382,7 +470,9 @@ async function handle(req, res) {
|
|||||||
if (nkVal) {
|
if (nkVal) {
|
||||||
for (const u2 of Object.keys(userData.tables[t])) {
|
for (const u2 of Object.keys(userData.tables[t])) {
|
||||||
if (u2 === row.uuid) continue
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,12 +501,18 @@ async function handle(req, res) {
|
|||||||
userData.tombstones[t][conflictUuid] = now
|
userData.tombstones[t][conflictUuid] = now
|
||||||
changed++
|
changed++
|
||||||
}
|
}
|
||||||
|
// 内容与已存行完全一致:不计变更、不触发保存(空推送不落盘)
|
||||||
|
if (!conflictUuid && existing && JSON.stringify(existing) === JSON.stringify(clean)) continue
|
||||||
userData.tables[t][row.uuid] = clean
|
userData.tables[t][row.uuid] = clean
|
||||||
changed++
|
changed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 应用墓碑
|
// 应用墓碑
|
||||||
const incomingTombs = body.tombstones || []
|
const incomingTombs = body.tombstones || []
|
||||||
|
if (incomingTombs.length >= LARGE_TOMBSTONE_BACKUP) {
|
||||||
|
// 大批删除的前置保险:先把当前数据快照留存,再应用删除(可追溯、可回滚)
|
||||||
|
try { doBackup(); console.log('[存储] 检测到大批删除(' + incomingTombs.length + ' 条墓碑),已先行快照') } catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
for (const tb of incomingTombs) {
|
for (const tb of incomingTombs) {
|
||||||
if (!TABLES.includes(tb.table) || !tb.uuid) continue
|
if (!TABLES.includes(tb.table) || !tb.uuid) continue
|
||||||
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
|
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
|
||||||
@@ -431,10 +527,11 @@ async function handle(req, res) {
|
|||||||
const tombs = userData.tombstones[t] || {}
|
const tombs = userData.tombstones[t] || {}
|
||||||
for (const [u, deletedAt] of Object.entries(tombs)) {
|
for (const [u, deletedAt] of Object.entries(tombs)) {
|
||||||
const row = userData.tables[t][u]
|
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')
|
if (changed) broadcast(auth.userId, 'change')
|
||||||
|
|
||||||
// 返回权威全量
|
// 返回权威全量
|
||||||
@@ -511,10 +608,7 @@ async function handle(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ----------
|
// ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ----------
|
||||||
function doBackup() {
|
function snapshotTo(dir) {
|
||||||
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 })
|
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, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ }
|
||||||
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
|
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
|
||||||
@@ -522,16 +616,45 @@ function doBackup() {
|
|||||||
const usersDir = path.join(DATA_DIR, 'users')
|
const usersDir = path.join(DATA_DIR, 'users')
|
||||||
if (fs.existsSync(usersDir)) {
|
if (fs.existsSync(usersDir)) {
|
||||||
for (const f of fs.readdirSync(usersDir)) {
|
for (const f of fs.readdirSync(usersDir)) {
|
||||||
if (f.endsWith('.json')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
|
if (f.endsWith('.json') && !f.includes('.corrupt-')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* 忽略 */ }
|
} catch { /* 忽略 */ }
|
||||||
// 保留最近 14 天
|
}
|
||||||
|
function pruneBackups() {
|
||||||
try {
|
try {
|
||||||
const backupsRoot = path.join(DATA_DIR, 'backups')
|
const root = path.join(DATA_DIR, 'backups')
|
||||||
const days = fs.readdirSync(backupsRoot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
|
const entries = fs.readdirSync(root).filter(d => /^\d{4}-\d{2}-\d{2}/.test(d))
|
||||||
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
|
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 { /* 忽略 */ }
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
function doBackup() {
|
||||||
|
try {
|
||||||
|
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) }
|
} catch (e) { console.error('backup failed:', e && e.message) }
|
||||||
void backupOffsite()
|
void backupOffsite()
|
||||||
}
|
}
|
||||||
@@ -541,7 +664,8 @@ setInterval(doBackup, 24 * 60 * 60 * 1000)
|
|||||||
// ---------- HTTP 服务 ----------
|
// ---------- HTTP 服务 ----------
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
handle(req, res).catch(err => {
|
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