46 Commits
Author SHA1 Message Date
root 7a36fd4e04 ui: 全站界面重设计(文字侧栏+灰底白卡层次+组件统一) fix: 添加记录弹窗不显示/计时汇总崩溃/CSV导出崩溃/记账切换类型崩溃 2026-10-26 12:40:54 +08:00
root 0f268a495e feat: 长期计划(目标→每日任务物化+进度条) / 详情页Markdown范围导出 / 记账与详情页改内容优先弹窗新增 2026-10-26 12:16:14 +08:00
root 284f8ebd46 feat: 计划模式(时间段+打卡+计时时钟) / 工作详情范围导出 / 工作与记账分类自定义(含历史迁移) 2026-10-26 11:48:14 +08:00
root a1f2a45623 ui: 侧边栏按“记录/工具/分析/系统”重排并分组分隔;精简设置/登录/记账页冗余文案(修正旧架构遗留说明);键盘焦点态 2026-10-17 11:07:04 +08:00
root 08b0317d35 security: 改密即吊销全部旧令牌 + 登录防爆破 + COS 异地备份(环境变量开关) + 启动失败重试按钮 2026-10-17 10:59:25 +08:00
root 56a60d5dc1 build: v0.3.0 网页产物 2026-10-16 22:38:55 +08:00
root 1f792127dc release: v0.3.0 2026-10-16 22:37:19 +08:00
root 344e70bf50 docs: 整理仓库结构——新增根 README(当前架构)、客户端 README 与部署手册更新至服务器权威模型,文档归位 docs/,清理误提交的打包产物 2026-10-16 22:35:55 +08:00
root aa773062fe chore: 移除 CI/Actions 相关设施(workflow、ci 脚本、tests 目录),审计脚本保留在 repro/ 供本地开发使用 2026-10-16 22:28:35 +08:00
root 0d334e8939 ci: 移除远程 action 依赖,改用内网 clone(github 不可达导致挂死)
CI / 测试套件(协议/用户管理/单测/双设备/实时链路/构建) (push) Canceled after 0s
CI / 依赖漏洞扫描(软性检查) (push) Canceled after 0s
2026-10-16 22:23:00 +08:00
root b33e6ff9b3 ci: 触发 runner 重建后首次完整运行
CI / 测试套件(协议/用户管理/单测/双设备/实时链路/构建) (push) Canceled after 0s
CI / 依赖漏洞扫描(软性检查) (push) Canceled after 0s
2026-10-16 21:48:35 +08:00
root f6f608a98b ci: job 容器镜像与包管理源适配国内网络
CI / 测试套件(协议/用户管理/单测/双设备/实时链路/构建) (push) Failing after 32m53s
CI / 依赖漏洞扫描(软性检查) (push) Failing after 19m20s
2026-10-16 21:44:12 +08:00
root 9bb626cd41 chore: 清理误提交到仓库根目录的 npm 工件
CI / 测试套件(协议/用户管理/单测/双设备/实时链路/构建) (push) Failing after 1m30s
CI / 依赖漏洞扫描(软性检查) (push) Failing after 30s
2026-10-16 21:04:16 +08:00
root 9a91c4c85d ci: 统一测试入口 scripts/ci.mjs(协议/用户管理/单测/双设备/实时链路/构建/依赖审计)+ Gitea Actions 工作流
CI / 测试套件(协议/用户管理/单测/双设备/实时链路/构建) (push) Failing after 30s
CI / 依赖漏洞扫描(软性检查) (push) Failing after 30s
2026-10-16 21:03:54 +08:00
root 3505e10961 feat: 用户管理(xionglin 为管理员)——服务端角色字段与 /api/admin/users 增删改查、客户端管理员专属入口 2026-10-16 18:24:56 +08:00
root cfc8e3c734 ui: 手机端布局重设计——底部带文字标签栏(5个主入口)+更多弹层、全屏搜索、打卡日历底部抽屉、弹窗贴底、记账卡片行、密码库横滑表格 2026-10-16 16:30:45 +08:00
root e5f99d46d8 architecture: 数据唯一权威迁至服务器——本地降级为实时缓存,写后即时推送+快照重建;账号登录转为云端原生(新增 /api/auth/password、/api/auth/delete、每日自动备份) 2026-10-16 16:13:38 +08:00
root 7517eb0ad9 fix: 登录后 showToast 未导入导致欢迎提示崩溃;顺带清除 auth.ts 死代码,补充 vue-tsc 类型工具 2026-10-16 15:41:57 +08:00
root 8fad42edc7 test: 新增实时链路回归(本地写入自动推送 + SSE 自动拉取) 2026-10-16 15:28:45 +08:00
root 9bd04441e1 fix: 设置页同步信息引用未定义变量导致的运行时崩溃(loadError/isSyncConfigured/autoOn) 2026-10-16 15:18:51 +08:00
root 04ae06ec1c fix: 打卡后即时推送 + 设置页展示自动同步失败原因 2026-10-16 15:09:03 +08:00
root aaf8ea602c fix: 输入校验统一字符串防御(数字输入不再引发 trim 类型崩溃) 2026-10-16 15:06:21 +08:00
root dba22765a6 fix: 同步引擎重写——外键重映射、自然键去重、墓碑传播;服务端冲突比较修正(双设备测试 12/12) 2026-10-16 14:54:29 +08:00
root 3f7b072ac6 fix: 应用启动即绑定用户数据库(修复刷新/登录后数据库未就绪的白屏) 2026-10-16 14:32:38 +08:00
root 8a0fde507f fix: 同步后刷新考勤视图 + 全局错误捕获层(白屏转错误报告) 2026-10-16 14:10:21 +08:00
root 8c9f2f0c8b feat: 移动端布局重做(顶栏两行、安全区、月历紧凑、表单单列) 2026-10-16 14:06:08 +08:00
root e721c356af fix: 账目加载错误常驻显示(含完整堆栈)便于定位 2026-10-16 13:59:05 +08:00
root 4d2bea139a fix: 同步应用循环数组解构错误(.for is not iterable) 2026-10-16 13:38:49 +08:00
root f387ac0194 fix: 同步引擎主键感知(setting 表字符串主键导致的 Invalid key) 2026-10-16 13:24:18 +08:00
root 5a67b2f59a fix: 修复 transaction 表名与 Dexie 内置方法冲突(导出 JSON 与记账失效) 2026-10-16 13:19:54 +08:00
root f0d30e2c29 fix: 桌面导出改用 IPC 直写(另存为对话框),设置页显示版本号 2026-10-16 13:07:38 +08:00
root 599aa09ab8 fix: 修复导出下载链路(Electron 保存对话框 + 原生文件写入 + revoke 竞态) 2026-10-16 13:02:33 +08:00
root b87e895766 release: v0.2.0(双端安装包 + 记账 + 多端同步) 2026-10-16 12:35:58 +08:00
root 3d05dcb61b docs: 部署时清除残留密钥说明 2026-10-16 12:06:40 +08:00
root 7aeae0a330 security: 移除误提交的服务端签名密钥(部署时会自动生成新密钥) 2026-10-16 12:06:26 +08:00
root b73d5ef2cc feat: 同步服务托管网页应用,daywk 单域名部署就绪 2026-10-16 12:05:47 +08:00
root acfb0cfdd2 feat: 登录即自动绑定云端并同步,免手动配置 2026-10-16 12:00:50 +08:00
root 8108f60825 build: 切换 Gradle 轻量发行包 2026-10-16 11:54:18 +08:00
root ba07899dbe feat: 准实时同步(变更 3 秒推送 + SSE 秒级拉取)并修复云端认证 2026-10-16 11:45:20 +08:00
root 052158d96d deploy: 同步服务独立子域名 daywk.linyux.cn 2026-10-16 11:37:51 +08:00
root dccd7c9d76 deploy: 适配服务器实际环境(mynet 网络、Caddy 路径反代) 2026-10-16 11:35:17 +08:00
root 5b48fadc46 deploy: 适配现有 Caddy 配置(gitea 域名路径反代 + 共享网络) 2026-10-16 11:33:13 +08:00
root 2660dc6fa9 feat: 登录会话持久化 + Caddy 反代部署方案 2026-10-16 11:27:15 +08:00
root de3add8881 feat: 多端同步服务端与引擎 + Capacitor 安卓工程(v1.13) 2026-10-16 11:07:37 +08:00
root ee795eeefb feat: 今日视图升级为详情视图(自由切换日期) 2026-10-16 10:50:40 +08:00
root 838753a058 feat: 新增记账系统(收支记录、报销跟踪、月度汇总) 2026-10-16 10:40:25 +08:00
130 changed files with 7155 additions and 789 deletions
+2
View File
@@ -0,0 +1,2 @@
server/data/
tmp/
+91
View File
@@ -0,0 +1,91 @@
# 实习工作日志(daywork)
面向实习生的个人工作管理工具:考勤打卡、工作记录、周报总结、备忘录、加密密码库、差旅记账。多端(桌面 / 网页 / 安卓)使用同一云端账号,数据实时互通。
- 在线实例:https://daywk.linyux.cn (网页版即应用本体,可安装为 PWA)
- 代码仓库:https://gitea.linyux.cn/root/daywork
- 设计方案:[docs/实习工作日志系统-设计方案.md](docs/实习工作日志系统-设计方案.md)
- 发布记录:[docs/release-notes-v0.2.0.md](docs/release-notes-v0.2.0.md)
## 架构
**服务器是唯一数据权威,各端只保留实时缓存。**
```
桌面(Electron) ─┐
网页(PWA) ─┼─ 写:立即推送差异 → 服务器确认 → 返回权威快照 → 本地缓存重建
安卓(Capacitor)─┘ 读:启动 / SSE 通知 / 60s 兜底 → 全量刷新缓存
│
▼
同步服务器(零依赖 Node,JSON 文件存储,每日自动备份)
· 注册表:用户名 + PBKDF2 哈希(角色:xionglin 为管理员)
· 业务数据:按用户隔离的九张表(project / work_log / log_item /
attendance / weekly_note / setting / memo / credential / transaction)
```
要点:
- **无合并逻辑**:本地缓存永远等于服务器快照,从结构上消灭双端分歧
- **密码库端到端加密**:主密码 PBKDF2 派生 AES-256-GCM 密钥在本机加解密,服务器只见密文
- **登录密码不上传明文**:服务端仅存 PBKDF2 哈希;本机存自动登录凭据(可清除)
- **用户管理**:管理员账号 xionglin 可在应用内新建 / 重置密码 / 删除用户(服务端鉴权)
## 功能
打卡(上下班、自动改时、补卡、休假、地点预设)· 工作日志(结构化条目、里程碑、待跟进)· 日历视图 · 统计 · 周报与 AI 润色(可选配置)· 周记提醒 · 备忘录 · 加密密码库 · 记账(报销跟踪)· 全局搜索 · JSON/CSV 备份导入导出 · 用户管理(管理员)
## 仓库结构
```
├─ intern-log/ 客户端(Vue 3 + TS + Vite + Dexie)
│ ├─ src/ 业务源码(views / components / db / sync / domain / lib)
│ ├─ electron/ 桌面壳(Electron 主进程 + 预加载)
│ ├─ android/ Capacitor 安卓工程
│ ├─ dist/ 网页构建产物(服务端容器直接托管,故随仓库提交)
│ ├─ release/ 打包产物(不入库,见 .gitignore)
│ └─ repro/ 开发验证脚本(同步引擎回归、协议审计、单测)
├─ server/ 同步服务端(零依赖 node:http + JSON 存储)
│ ├─ index.js 全部服务端逻辑(认证 / 同步 / SSE / 用户管理 / 备份)
│ ├─ Dockerfile / docker-compose.yml
│ └─ DEPLOY.md 部署手册(域名 / Caddy / 运维)
└─ docs/ 设计方案与发布记录
```
## 开发
```bash
cd intern-log
pnpm install
pnpm dev # http://localhost:5173
pnpm build # 产出 dist/(服务端容器托管的就是它)
```
桌面打包:`pnpm dist`(产物 release/InternLog-Setup-*.exe)
安卓打包:`pnpm build && npx cap sync android`,然后 `android/gradlew.bat assembleDebug`
## 测试(本地验证脚本,位于 intern-log/repro/)
```bash
# 起本地同步服务后运行:
node repro/protocol-audit.mjs # 服务端协议审计(认证/九表/LWW/墓碑/SSE/备份)
node repro/admin-audit.mjs # 用户管理审计
# 引擎级测试需先 esbuild 打包(详见脚本头部注释):
# two-device.ts 双设备端到端回归
# engine-realtime.ts 实时链路(写入自动推送 + SSE 自动拉取)
# unit-tests.ts 纯函数单测(日期/考勤/加密)
```
## 部署
见 [server/DEPLOY.md](server/DEPLOY.md)。更新流程:
```bash
cd /opt/daywork && git pull && cd server && docker compose up -d --build
```
## 安全与隐私
- 登录密码、云端密码:仅 PBKDF2(15 万次迭代)哈希,任何一端不存明文
- 密码库:AES-256-GCM,主密钥仅存在于解锁后的内存,主密码永不出机
- 服务端每日自动备份到 server/data/backups/(保留 14 天),注册表变更即触发备份
- 界面无表情符号,图标全部自绘 SVG
+22
View File
@@ -0,0 +1,22 @@
# 实习工作日志 v0.2.0
## 新增(相比 v0.1.0)
- **详情视图**:原「今日」升级,支持自由切换任意日期进行查看、打卡、记录与修改
- **记账系统**:支出/收入记录、分类、报销跟踪(待报销/已报销与待报销金额汇总)、月度汇总与分类分布
- **多端数据同步**:自建同步服务端(daywk.linyux.cn),电脑、手机、网页多端数据互通,准实时(变更 3 秒推送 + SSE 秒级拉取)
- **登录即同步**:登录后自动绑定云端并同步,零手动配置
- 全局搜索(Ctrl+K)、考勤休假标记、CSV 导出、总结 AI 润色(可配置)、密码库自动锁定等
## 下载
| 附件 | 说明 |
|---|---|
| InternLog-Setup-0.2.0.exe | Windows 安装包 |
| InternLog-0.2.0-debug.apk | 安卓应用(允许未知来源安装) |
## 升级说明
- Windows:直接运行安装包覆盖安装,数据保留
- 安卓:覆盖安装 APK,数据保留
- 多端同步需先部署同步服务端(见仓库 server/DEPLOY.md),两端使用相同的登录用户名与密码即可自动互通
+41
View File
@@ -0,0 +1,41 @@
# v0.3.0 发布记录
发布日期:2026-10-16
## 架构:数据唯一权威迁至服务器
- 本地数据库降级为实时缓存:所有写入立即推送服务器,并以服务器快照全量重建,双端分歧从结构上消失
- 登录 / 注册全部经由服务器(本机仅存自动登录凭据);换设备用同一用户名密码即可恢复全部数据
- 服务端每日自动备份(保留 14 天),注册表变更即触发
## 新功能:用户管理
- 管理员账号 xionglin 可在应用内管理云端用户:新建、重置密码、删除(服务端鉴权,非管理员 403)
- 桌面端侧边栏与手机端「更多」弹层提供入口
## 手机端布局重设计
- 底部带文字标签栏(日历 / 详情 / 记账 / 备忘录 / 更多),低频入口收进「更多」底部弹层
- 全屏搜索层;打卡日历当日详情改底部抽屉;所有弹窗贴底、表单单列
- 记账表格转卡片行;密码库宽表横向滑动;桌面端布局不受影响
## 同步可靠性
- 打卡后即时推送(3 秒防抖)+ SSE 秒级拉取(实测 257ms 落库)
- 设置页显示自动同步失败原因,便于定位
## 修复
- 设置页同步信息引用未定义变量导致的运行时崩溃(loadError / showToast 两例)
- 数字输入框类型防御(a.value.trim 崩溃)
- 服务端冲突比较修正、外键重映射与自然键去重(双设备回归 12/12)
## 质量基线
- 服务端协议审计 34 项、用户管理审计 16 项、双设备回归 12 项、实时链路 6 项、纯函数单测 9 项,全部通过
- 仓库结构整理:根 README、文档归位 docs/、清理误提交产物
## 升级说明
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
- 客户端:覆盖安装后用原云端用户名密码登录,数据自动拉回
+25
View File
@@ -0,0 +1,25 @@
# v0.4.0 发布记录
发布日期:2026-10-16
## 新功能:计划模式
- 新增「计划」页:按天规划时间段(开始/结束时间、类别、备注),侧边栏"记录"组新增入口,手机端底部标签直达
- 条目级打卡:完成 / 跳过 / 恢复,日汇总(计划项数、完成数、计划时长、实际投入)
- 计时时钟:任意条目一键开始/停止计时,自动累计实际投入分钟;同刻仅一个计时,开始新计时自动停止上一个
- 计划数据进入同步体系,多端实时互通
## 新功能:范围化导出
- 设置 → 工作详情导出:当日 / 当周 / 当月 / 自定义时间段一键导出 CSV(含分类、项目、耗时、成果、待跟进、收获、备注)
## 新功能:分类自定义
- 工作记录分类改为用户自定义(默认:制度学习 / 产品学习 / 打杂 / 项目实操 / 售后服务),设置页可添加、改名、删除,随账号同步
- 记账支出 / 收入分类同样自定义,记账弹窗内可直接"新增分类"
- 历史数据自动迁移:旧固定分类(开发/BUG修复/学习成长…)按语义映射到新分类,报表与统计自动跟随
## 升级说明
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build(需包含本次 plan 表的服务端支持)
- 客户端覆盖安装即可,本地库自动升级到 v5 并完成分类迁移
+25
View File
@@ -0,0 +1,25 @@
# v0.5.0 发布记录
发布日期:2026-10-16
## 计划模式增强:长期计划
- 长期计划(目标)跨越多天:设置起止日期后自动**物化为每一天的任务**,落实到每天打卡与计时
- 目标进度条:已完成天数 / 总天数一目了然;编辑目标自动级联更新未完成的每日任务
- 每日任务带「长期」徽标,可与当日临时计划混排;删除目标时二次确认
- 数据模型新增 goal 表(v6),goal ↔ plan 外键随多端同步(引擎级冒烟验证外键水合正确)
## 界面原则:内容优先
- 记账页:移除内联新增表单,改为「记一笔」按钮弹窗填写,月度汇总与账目表前置
- 当日详情:移除内联快速记录表单,改为「添加记录」按钮弹窗填写,记录列表前置
- 工作详情导出从设置页移至详情页,并支持 **Markdown 格式**(当日 / 当周 / 当月 / 自定义时间段,含考勤与逐条记录)
## 上一版(v0.4.0)要点回顾
计划模式(时间段+打卡+计时时钟)、工作与记账分类自定义、工作详情范围导出 CSV
## 升级说明
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build
- 客户端覆盖安装;本地库自动升级至 v6
+29
View File
@@ -0,0 +1,29 @@
# v0.6.0 发布说明(2026-10-26)
## 界面整体重设计
本版本对全部界面做了系统性改版,统一为「高级白」企业级视觉,信息层次更清晰:
- **桌面侧栏重做**:由纯图标改为「图标 + 文字」导航,按 记录 / 工具 / 分析 / 系统 分组;新增品牌区与当前用户区(头像、用户名、退出)。
- **顶栏精简**:只保留页面标题、全局搜索与打卡按钮;用户信息与退出移入侧栏底部。
- **内容层次**:内容区改为浅灰底 + 白色卡片,卡片带细边框与轻投影,主次分明。
- **组件统一**:按钮、输入框、表格、弹窗、分段选择器、进度条等全部收敛为全局统一样式,各页面不再各自为政。
- **逐页重排**:日历(网格线 + 选中描边)、详情(导航与导出合并为一条工具栏)、计划(长期计划与每日安排分区块、列表合并为单卡)、记账、统计(热力图改品牌蓝)、总结、备忘录、密码库、设置、用户管理。
- **登录页升级**:品牌标识、卡片投影。
- **平板适配**:721–1000px 侧栏自动折叠为图标栏。
- **移动端**:底部标签栏与「更多」弹层沿用,并随新基座精修间距与弹窗。
## 问题修复
- 修复:详情页「添加记录」按钮点击无反应(弹窗开关与编辑目标状态混用所致)。
- 修复:计划页有任务正在计时时,汇总行会触发未定义函数导致整页报错。
- 修复:设置页导出工作记录 CSV 缺少分类标签导入,触发即报错。
- 修复:记账弹窗切换收支类型时引用了不存在的分类常量,触发即报错。
- 记录弹窗标题与按钮文案随「新增 / 编辑」模式正确切换。
## 升级与部署
- 桌面端:安装 InternLog-Setup-0.6.0.exe。
- 安卓端:安装 InternLog-0.6.0-debug.apk。
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端 bundle 随仓库更新)。
- 数据结构无变更,v0.5.0 及之前版本可直接升级。
@@ -1,6 +1,6 @@
# 实习工作日志系统 设计方案
- 版本:v1.10(Electron 桌面应用)
- 版本:v1.13(多端同步与安卓应用)
- 范围:个人版全量功能;团队版仅作扩展预留
## 1. 产品定位
@@ -154,6 +154,39 @@ AI 润色为可选进阶:将统计、成果、周记交给大模型按四段
| 账号管理 | 设置页可修改登录密码、删除用户(连同其全部数据,二次确认) |
| 旧数据迁移 | 首次创建用户时可勾选迁移升级前单机数据,迁移仅复制、原库保留兜底 |
### 3.12 记账系统
| 项 | 说明 |
|---|---|
| 录入 | 支出/收入两类型;金额以「分」为整数存储避免浮点误差;日期、分类(支出:餐饮/交通/住房/通讯/学习/娱乐/其他;收入:工资/报销/其他,固定枚举)、备注 |
| 报销跟踪 | 支出可标记「需要报销」并跟踪待报销/已报销状态,面向出差垫付场景;汇总卡片显示待报销金额 |
| 汇总 | 按月展示收入、支出、结余、待报销四卡片;支出分类分布条形图 |
| 明细 | 月度流水表格,类型与报销状态筛选,支持弹窗编辑、删除确认、标记已报销 |
### 3.13 多端同步与安卓应用
**同步服务端**(server 目录,零依赖 Node,Docker 部署于自有服务器):
| 项 | 说明 |
|---|---|
| 存储 | JSON 文件按用户隔离(users/用户ID.json),注册表独立;整目录复制即备份 |
| 认证 | 登录密码 PBKDF2 哈希;HMAC 签名 token(30 天有效期) |
| 接口 | 注册 / 登录 / 全量同步 / 健康检查;CORS 开放以支持手机浏览器 |
| 同步模型 | 客户端全量差异对比 + 记录级新者胜(updated_at)+ 软删除墓碑;本地 IndexedDB 始终为主存储,离线可用 |
**客户端同步引擎**(src/sync/engine.ts):
- 一次性补齐每行 uuid 与 updated_at;镜像表(sync_mirror)做哈希差异检测
- 删除通过墓碑表传播到服务端与其他端
- 设置页配置同步地址与云端账号;登录后自动同步(5 分钟间隔,静默),支持立即手动同步
- 密码库以密文同步,主密码永不出本机;本地临时设置(上次项目、分类等)不同步
**安卓应用(Capacitor)**:
- 复用全部前端代码;npx cap add android 生成工程,pnpm cap:sync 同步资源
- 本机构建 APK 需 Android Studio(SDK + JDK 17):进入 android 目录执行 gradlew assembleDebug,产物在 app/build/outputs/apk/debug/
- 分发:apk 直接安装(允许未知来源),或上架应用市场
## 4. 数据模型(SQLite)
### 4.1 建表语句
@@ -232,6 +265,19 @@ CREATE TABLE credential (
updated_at TEXT NOT NULL
);
-- 记账流水(amount 单位为分)
CREATE TABLE transaction (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
category TEXT NOT NULL,
amount INTEGER NOT NULL,
date TEXT NOT NULL,
note TEXT,
reimbursable INTEGER NOT NULL DEFAULT 0,
reimbursed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- 设置
CREATE TABLE setting (
key TEXT PRIMARY KEY,
@@ -284,7 +330,7 @@ dev 开发 / bugfix BUG修复 / test 测试 / learning 学习成长 / meeting
| 视图 | 内容 |
|---|---|
| 日历(默认首页) | 月历网格;点击日期开右侧当日抽屉:打卡时间线 + 条目列表 + 快速记录 |
| 今日 | 打卡状态卡 / 快速记录区 / 今日条目列表 / 自动小结(x 条 · y 小时) |
| 详情(原今日) | 日期自由切换(前后翻页 + 日期选择器 + 回到今天);DayPanel 提供打卡、快速记录、条目编辑与删除等全部当日操作 |
| 统计 | 概览卡片行 / 记录热力图 / 分类与项目条形图 |
| 总结 | 范围选择 + 左侧草稿编辑 + 右侧数据摘要 + 导出 Markdown |
| 设置 | 打卡规则 / 项目管理 / 周记提醒 / JSON 备份导入导出 |
@@ -482,3 +528,10 @@ src/
- v1.8:新增用户系统——每位用户独立 IndexedDB 数据库物理隔离数据;登录密码仅存 PBKDF2 哈希;会话存 sessionStorage 并支持 30 分钟无操作自动退出;设置页支持修改登录密码与删除用户;首次创建用户可一键迁移升级前单机数据(原库保留兜底)
- v1.9:PWA 应用化——新增 Web 应用清单、Service Worker 离线缓存与自绘应用图标;在 Edge/Chrome 中可一键安装为独立窗口应用(桌面图标、开始菜单入口),离线可用,数据随浏览器配置文件存储
- v1.10:Electron 桌面应用——pnpm dist 一键产出 NSIS 安装包与免安装目录;主进程加载本地构建产物,数据存于系统用户目录;窗口禁用菜单栏、外部链接交由系统浏览器打开
- v1.11:新增记账系统——支出/收入录入(金额以分存储)、固定分类枚举、报销跟踪(待报销/已报销与待报销金额汇总)、月度汇总卡片与支出分类分布、月度流水筛选与弹窗编辑;备份格式升级至 v3 并向下兼容
- v1.12:「今日」视图升级为「详情」——支持前后翻页、日期选择器自由切换日期,对任意一天进行查看、打卡、记录与修改
- v1.13:多端同步与安卓应用——自建零依赖同步服务端(Docker 部署、按用户 JSON 隔离、HMAC token);客户端同步引擎(uuid 与 updated_at 补齐、镜像哈希差异、墓碑传播、记录级新者胜);设置页同步配置与自动同步;Capacitor 安卓工程脚手架与移动端 UI 适配(安全区、输入字号)
- v1.13.1:登录会话持久化(localStorage,关闭浏览器后免登录,无需每次使用都登录);部署方案调整为 Docker + Caddy 自动 HTTPS(独立子域名与现有域名路径两种反代方式);移除 30 分钟无操作自动退出(密码库仍保留 5 分钟自动锁定与主密码门槛)
- v1.13.2:同步服务独立子域名部署 daywk.linyux.cn(Caddy 新站点块,客户端同步地址 https://daywk.linyux.cn)
- v1.13.3:准实时同步——本地数据变更 3 秒内自动推送(Dexie 变更事件触发);服务端新增 SSE 事件流,其他端变更秒级拉取;60 秒轮询兜底;修复引擎与服务端认证方式不匹配问题(登录换取 HMAC token 并缓存,失效自动重登);同步并发加锁防重入
- v1.13.4:同步服务同时托管网页应用构建产物——daywk.linyux.cn 单域名即应用入口与数据接口,手机 PWA 零额外部署;dist 随仓库分发,服务器克隆后一次构建全部完成
+6 -1
View File
@@ -1,4 +1,9 @@
node_modules/
dist/
release/
repro/*.cjs
*.local
android/.gradle/
android/build/
android/app/build/
android/local.properties
server/data/
+32 -20
View File
@@ -1,30 +1,42 @@
# 实习工作日志
# intern-log 客户端
本地优先的实习工作记录工具:考勤打卡、月历、随手记、周记、一键生成总结。
实习工作日志的多端客户端(网页 / Electron 桌面 / Capacitor 安卓共用同一套源码)。
对应设计方案文档:实习工作日志系统-设计方案.md
整体架构、部署与安全设计见仓库根目录 [README](../README.md);设计方案见 [docs/实习工作日志系统-设计方案.md](../docs/实习工作日志系统-设计方案.md)。
## 运行
pnpm install
pnpm dev # 开发模式,默认 http://localhost:5173
pnpm build # 构建产物到 dist/
pnpm build # 构建产物到 dist/(服务端容器托管的就是它)
pnpm preview # 预览构建产物,默认 http://localhost:4173
## 说明
## 数据模型(重要)
- 技术栈:Vue 3 + TypeScript + Vite + Dexie(IndexedDB),无后端、无外部图标库
- 数据保存在浏览器 IndexedDB(数据库名 intern_worklog)
- 「设置」页可导出/导入 JSON 全量备份;「总结」页可导出 Markdown
- 支持上班地点记录(公司、客户现场、出差等),可直接输入自定义地点并自动记住,统计与总结含地点分布
- 打卡时间支持行内直接修改(含补录下班时间),考勤状态自动重算
- 表单带标签与必填校验;删除与备份覆盖导入使用统一确认弹窗;操作结果轻提示反馈
- 工作记录支持弹窗完整信息编辑(全部字段),点击条目或编辑图标打开
- 备忘录:双栏列表与编辑,未保存修改有守卫
- 密码库:企业账号密码管理,主密码 PBKDF2 派生 AES-256-GCM 加密,支持解锁、锁定、搜索、复制、随机密码生成、修改主密码;备份文件中密码保持密文
- 用户系统:每位用户独立 IndexedDB 数据库隔离存储;登录密码仅存 PBKDF2 哈希;30 分钟无操作自动退出;创建用户时可迁移升级前数据
- PWA:在 Edge/Chrome 中打开后,地址栏右侧点「安装」或菜单选择「将此站点作为应用安装」,即可获得独立窗口与桌面图标,离线可用
- Electron 桌面版:pnpm dist 产出 release/InternLog-Setup-<版本>.exe(NSIS 安装包)与 release/win-unpacked/InternLog.exe(免安装版);数据存于系统用户目录,与浏览器数据互不影响
- 全局搜索:Ctrl+K 唤起,覆盖工作记录与备忘录,点击结果跳转定位
- 考勤支持休假标记;支持导出工作记录与考勤 CSV;总结草稿可复制、可 AI 润色(设置页配置 OpenAI 兼容接口);密码库 5 分钟无操作自动锁定
- 图标为自绘 SVG(src/components/icons.ts),界面不含表情与特殊符号
- **服务器是唯一数据权威**,本地 Dexie(IndexedDB)只是实时缓存:
每次写操作立即推送差异到服务器,并以服务器返回的快照全量重建缓存
- 同步引擎:`src/sync/engine.ts`(差异推送 + 快照重建 + SSE 实时通知 + 60s 兜底)
- 本地库按云端用户隔离:`intern_worklog_<云端userId>`
## 目录速览
src/
├─ views/ 页面(日历/详情/统计/总结/记账/备忘录/密码库/设置/用户管理)
├─ components/ 通用组件与自绘 SVG 图标(icons.ts)
├─ db/ schema(Dexie)/ repo(数据仓库,写后即推)/ auth(云端登录)/ vault(加密密码库)
├─ sync/ 同步引擎
├─ domain/ 考勤计算 / AI 润色
└─ lib/ 日期、加密、下载、toast、错误浮层、服务器基址
## 打包
- 桌面:`pnpm dist` → release/InternLog-Setup-*.exe(NSIS)与 win-unpacked/
- 安卓:`pnpm build && npx cap sync android` → android/gradlew.bat assembleDebug
(需 JDK 17:set JAVA_HOME=C:\Program Files\Java\jdk-17)
## 验证脚本(repro/)
开发期回归工具,不参与构建:
- `protocol-audit.mjs` / `admin-audit.mjs`:对本地同步服务(127.0.0.1:3780)的协议级审计
- `two-device.ts` / `engine-realtime.ts` / `unit-tests.ts`:引擎级与单元测试(用 esbuild 打包后 node 运行,命令见文件头注释)
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace "cn.linyux.internlog"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "cn.linyux.internlog"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-filesystem')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,5 @@
package cn.linyux.internlog;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">实习工作日志</string>
<string name="title_activity_main">实习工作日志</string>
<string name="package_name">cn.linyux.internlog</string>
<string name="custom_url_scheme">cn.linyux.internlog</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@6.2.2_@capacitor+core@6.2.2/node_modules/@capacitor/android/capacitor')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@6.0.4_@capacitor+core@6.2.2/node_modules/@capacitor/filesystem/android')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 22
compileSdkVersion = 34
targetSdkVersion = 34
androidxActivityVersion = '1.8.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.12.0'
androidxFragmentVersion = '1.6.2'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.9.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
}
+8
View File
@@ -0,0 +1,8 @@
{
"appId": "cn.linyux.internlog",
"appName": "实习工作日志",
"webDir": "dist",
"server": {
"androidScheme": "https"
}
}
+2
View File
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-BbcS1QA8.js","./index-CtHE6m7N.js","./index-C8-KlPfD.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-CtHE6m7N.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-BbcS1QA8.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="1.6" y="1.6" width="20.8" height="20.8" rx="4.6" fill="#3370FF"/>
<rect x="5.8" y="7.2" width="12.4" height="11.5" rx="1.1" fill="#FFFFFF"/>
<rect x="5.8" y="7.2" width="12.4" height="2.2" fill="#3370FF"/>
<rect x="7.9" y="4.9" width="1.1" height="3.2" rx="0.5" fill="#FFFFFF"/>
<rect x="15.0" y="4.9" width="1.1" height="3.2" rx="0.5" fill="#FFFFFF"/>
<circle cx="8.6" cy="12.4" r="0.62" fill="#3370FF"/>
<circle cx="12" cy="12.4" r="0.62" fill="#3370FF"/>
<circle cx="15.4" cy="12.4" r="0.62" fill="#3370FF"/>
<circle cx="8.6" cy="15.4" r="0.62" fill="#3370FF"/>
<circle cx="12" cy="15.4" r="0.62" fill="#3370FF"/>
<circle cx="15.4" cy="15.4" r="0.62" fill="#3370FF"/>
</svg>

After

Width:  |  Height:  |  Size: 770 B

+23
View File
@@ -0,0 +1,23 @@
from PIL import Image, ImageDraw
ACCENT = (51, 112, 255, 255)
WHITE = (255, 255, 255, 255)
def make(size, path):
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
m = size * 0.07
d.rounded_rectangle([m, m, size - m, size - m], radius=size * 0.19, fill=ACCENT)
x0, y0, x1, y1 = size * 0.24, size * 0.30, size * 0.76, size * 0.78
d.rounded_rectangle([x0, y0, x1, y1], radius=size * 0.045, fill=WHITE)
d.rectangle([x0, y0 + size * 0.045, x1, y0 + size * 0.09], fill=ACCENT)
for cx in (0.36, 0.64):
d.rounded_rectangle([size * cx - size * 0.02, size * 0.20, size * cx + size * 0.02, size * 0.33], radius=size * 0.02, fill=WHITE)
for dx in (0.34, 0.5, 0.66):
for dy in (0.5, 0.63):
d.ellipse([size * dx - size * 0.025, size * dy - size * 0.025, size * dx + size * 0.025, size * dy + size * 0.025], fill=ACCENT)
img.save(path)
make(192, 'icon-192.png')
make(512, 'icon-512.png')
print('icons generated')
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#FFFFFF" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-CtHE6m7N.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C8-KlPfD.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"name": "实习工作日志",
"short_name": "工作日志",
"description": "实习打卡、工作记录、备忘录与加密密码库",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#FFFFFF",
"theme_color": "#FFFFFF",
"icons": [
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "./icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
+46
View File
@@ -0,0 +1,46 @@
const CACHE = 'intern-log-v1'
const CORE = ['./', './index.html']
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(c => c.addAll(CORE)).then(() => self.skipWaiting()))
})
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
)
})
self.addEventListener('fetch', event => {
const req = event.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== location.origin) return
// 页面导航:网络优先,离线回退缓存的应用壳
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then(resp => {
const clone = resp.clone()
caches.open(CACHE).then(c => c.put('./index.html', clone))
return resp
})
.catch(() => caches.match('./index.html'))
)
return
}
// 静态资源:缓存优先,未命中时拉取并写入缓存
event.respondWith(
caches.match(req).then(hit => hit || fetch(req).then(resp => {
if (resp.ok) {
const clone = resp.clone()
caches.open(CACHE).then(c => c.put(req, clone))
}
return resp
}))
)
})
+29 -1
View File
@@ -1,5 +1,6 @@
const { app, BrowserWindow, Menu, shell } = require('electron')
const { app, BrowserWindow, Menu, shell, dialog, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const DEV_URL = process.env.VITE_DEV_SERVER_URL || ''
@@ -16,10 +17,25 @@ function createWindow() {
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.cjs'),
spellcheck: false
}
})
Menu.setApplicationMenu(null)
// 下载接管:导出的 JSON/CSV/Markdown 弹出标准保存对话框
win.webContents.session.on('will-download', (event, item, webContents) => {
const result = dialog.showSaveDialogSync(win, {
title: '保存文件',
defaultPath: path.join(app.getPath('downloads'), item.getFilename())
})
if (!result) {
event.preventDefault()
return
}
item.setSavePath(result)
})
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:/i.test(url)) shell.openExternal(url)
return { action: 'deny' }
@@ -31,6 +47,18 @@ function createWindow() {
}
}
// 导出文件:渲染进程请求主进程保存(标准另存为对话框 + 直接写盘)
ipcMain.handle('export-file', async (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showSaveDialog(win, {
title: '保存文件',
defaultPath: path.join(app.getPath('downloads'), payload.filename)
})
if (result.canceled || !result.filePath) return { saved: false, path: '' }
fs.writeFileSync(result.filePath, payload.content, 'utf-8')
return { saved: true, path: result.filePath }
})
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
+5
View File
@@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (filename, content) => ipcRenderer.invoke('export-file', { filename, content })
})
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#FFFFFF" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
+11 -4
View File
@@ -1,25 +1,32 @@
{
"name": "intern-log",
"private": true,
"version": "0.1.0",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"electron": "electron electron/main.cjs",
"dist": "vite build && electron-builder"
"dist": "vite build && electron-builder",
"cap:sync": "vite build && npx cap sync android"
},
"dependencies": {
"@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.2",
"dexie": "^4.0.8",
"vue": "^3.5.13"
},
"devDependencies": {
"@capacitor/android": "^6.2.0",
"@capacitor/cli": "^6.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"fake-indexeddb": "^6.2.5",
"typescript": "~5.6.3",
"vite": "^5.4.11",
"electron": "^33.2.0",
"electron-builder": "^25.1.8"
"vue-tsc": "^2.2.12"
},
"description": "实习工作日志:打卡、记录、备忘录与密码库",
"main": "electron/main.cjs",
+531 -5
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1,3 +1,8 @@
allowBuilds:
esbuild: true
electron: true
# 允许执行安装脚本的依赖(esbuild 需要准备平台二进制)
onlyBuiltDependencies:
- esbuild
- electron
+52
View File
@@ -0,0 +1,52 @@
// 用户管理审计:角色 / 列表 / 新建 / 重置密码 / 删除 / 越权防护
const BASE = process.env.SYNC_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 ? ' -> ' + String(JSON.stringify(detail)).slice(0, 160) : '')) }
}
async function req(path, method, body, token) {
const r = await fetch(BASE + path, {
method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
body: body ? JSON.stringify(body) : undefined
})
return { status: r.status, data: await r.json().catch(() => ({})) }
}
async function main() {
const ax = await req('/api/auth/register', 'POST', { username: 'xionglin', password: 'admin123' })
check('xionglin 注册成功', ax.status === 200, ax.data)
const nu = await req('/api/auth/register', 'POST', { username: 'normal_u', password: 'normal123' })
check('普通用户注册成功', nu.status === 200, nu.data)
const listNoAuth = await req('/api/admin/users', 'GET', null, nu.data.token)
check('非管理员访问用户列表 403', listNoAuth.status === 403, listNoAuth.status)
const badList = await req('/api/admin/users', 'GET', null)
check('无 token 访问用户列表 403', badList.status === 403, badList.status)
const list = await req('/api/admin/users', 'GET', null, ax.data.token)
check('管理员可见用户列表', list.status === 200 && ((list.data.users || []).some(u => u.username === 'normal_u') && (list.data.users.some(u => u.username === 'xionglin' && u.role === 'admin'))), list.data)
check('列表返回 me 字段', !!list.data.me)
const create = await req('/api/admin/users/create', 'POST', { username: 'target_u', password: 'pass111' }, ax.data.token)
check('管理员新建用户', create.status === 200 && !!create.data.userId, create.data)
const dupCreate = await req('/api/admin/users/create', 'POST', { username: 'target_u', password: 'pass222' }, ax.data.token)
check('重复用户名 409', dupCreate.status === 409, dupCreate.status)
const loginTarget = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass111' })
check('新用户可用初始密码登录', loginTarget.status === 200, loginTarget.status)
const reset = await req('/api/admin/users/password', 'POST', { userId: create.data.userId, newPassword: 'pass999' }, ax.data.token)
check('管理员重置密码 200', reset.status === 200, reset.data)
const oldLogin = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass111' })
check('旧密码失效 401', oldLogin.status === 401, oldLogin.status)
const newLogin = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass999' })
check('重置后新密码可登录', newLogin.status === 200, newLogin.status)
const selfDel = await req('/api/admin/users/delete', 'POST', { userId: ax.data.userId }, ax.data.token)
check('不能删除自己 400', selfDel.status === 400, selfDel.status)
const del = await req('/api/admin/users/delete', 'POST', { userId: create.data.userId }, ax.data.token)
check('删除用户 200', del.status === 200, del.data)
const goneLogin = await req('/api/auth/login', 'POST', { username: 'target_u', password: 'pass999' })
check('删除后登录被拒 401', goneLogin.status === 401, goneLogin.status)
const list2 = await req('/api/admin/users', 'GET', null, ax.data.token)
check('列表不再含已删用户', !((list2.data.users || []).some(u => u.username === 'target_u')))
console.log('==== 用户管理审计: PASS ' + pass + ' / FAIL ' + fail + ' ====')
process.exit(fail > 0 ? 1 : 0)
}
main().catch(e => { console.error('FAILED:', e && (e.stack || e.message)); process.exit(1) })
+112
View File
@@ -0,0 +1,112 @@
// 实时链路引擎级验证:本地写入 -> 3s 防抖自动推送;外部端写入 -> SSE -> 自动拉取
import 'fake-indexeddb/auto'
const BASE = 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name: string, ok: boolean, detail?: unknown): void {
if (ok) { pass++; console.log(' PASS ' + name) }
else { fail++; console.log(' FAIL ' + name + (detail !== undefined ? ' -> ' + String(JSON.stringify(detail)).slice(0, 200) : '')) }
}
// Node 没有 localStorage:先垫片再导入 auth/engine
const store = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
setItem: (k: string, v: string) => { store.set(k, String(v)) },
removeItem: (k: string) => { store.delete(k) }
}
store.set('dsh_cloud_creds', JSON.stringify({ username: 'twodev', password: 'dev123456' }))
store.set('dsh_session_user', JSON.stringify({ userId: 'local_rt', username: 'twodev' }))
// Node 24 自带 EventSource;老环境则垫一个基于 fetch 流的最小实现
if (typeof (globalThis as any).EventSource === 'undefined') {
class MiniES {
onmessage: ((e: { data: string }) => void) | null = null
onerror: (() => void) | null = null
private ab = new AbortController()
constructor(url: string) {
fetch(url, { signal: this.ab.signal }).then(async r => {
const dec = new TextDecoder()
let buf = ''
const reader = (r.body as any).getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value)
while (buf.includes('event: change')) {
buf = buf.slice(buf.indexOf('event: change') + 'event: change'.length)
const cb = this.onmessage
if (cb) cb({ data: '{}' })
}
}
}).catch(() => { const cb = this.onerror; if (cb) cb() })
}
close(): void { this.ab.abort() }
}
;(globalThis as any).EventSource = MiniES
}
async function main() {
const schema = await import('../src/db/schema')
const bindDatabase = schema.bindDatabase, WorklogDB = schema.WorklogDB
const engine = await import('../src/sync/engine')
const repo = await import('../src/db/repo')
check('Node 有 EventSource(原生或垫片)', typeof (globalThis as any).EventSource !== 'undefined')
bindDatabase(new WorklogDB('rt_a'))
store.set('dsh_sync_base', BASE) // 测试环境同步基址走本地覆盖
engine.enableRealtimeSync()
// ---- 场景1:本地写入 -> 防抖 3s -> 自动推送 ----
await schema.db.project.add({ name: '实时推送项目', archived: 0, created_at: '2026-01-01T00:00:00' })
const t0 = performance.now()
const login = await fetch(BASE + '/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'twodev', password: 'dev123456' })
})
const tok = (await login.json()).token as string
async function serverSnapshot(): Promise<Record<string, any>> {
const r = await fetch(BASE + '/api/sync', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + tok },
body: JSON.stringify({ tables: {} })
})
return (await r.json()).tables
}
let pushedAt = -1
for (let i = 0; i < 30; i++) {
await new Promise(r => globalThis.setTimeout(r, 300))
const snap = await serverSnapshot()
if ((snap.project || []).some((p: any) => p.name === '实时推送项目')) { pushedAt = performance.now() - t0; break }
}
check('本地写入 4.5 秒内自动推送上云', pushedAt > 0, 'syncLastError=' + engine.syncLastError.value)
if (pushedAt > 0) console.log(' INFO 到云端可见耗时: ' + Math.round(pushedAt) + ' ms')
check('自动推送未留下错误', engine.syncLastError.value === '', engine.syncLastError.value)
// ---- 场景2:外部端写入 -> SSE 通知 -> 本端自动拉取 ----
await fetch(BASE + '/api/sync', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + tok },
body: JSON.stringify({ tables: { memo: [{ uuid: 'ext-memo-1', title: '来自另一端', content: 'sse', updated_at: '2026-02-03T00:00:00' }] } })
})
const t1 = performance.now()
let pulledAt = -1
for (let i = 0; i < 25; i++) {
await new Promise(r => globalThis.setTimeout(r, 250))
const got = await schema.db.memo.toArray()
if (got.some(m => (m as any).uuid === 'ext-memo-1')) { pulledAt = performance.now() - t1; break }
}
check('外部端写入后 6 秒内自动拉取到本地', pulledAt > 0, 'syncLastError=' + engine.syncLastError.value)
if (pulledAt > 0) console.log(' INFO SSE 通知到本地落库耗时: ' + Math.round(pulledAt) + ' ms')
check('自动拉取未留下错误', engine.syncLastError.value === '', engine.syncLastError.value)
// ---- 场景3:autoSyncNow 手动失败可见性(断開服务)----
engine.stopRealtime()
store.set('dsh_sync_base', 'http://127.0.0.1:9') // 服务不可达
await engine.autoSyncNow()
check('服务不可达时 syncLastError 被记录', engine.syncLastError.value !== '', engine.syncLastError.value)
store.set('dsh_sync_base', BASE)
console.log('==== 实时链路审计: PASS ' + pass + ' / FAIL ' + fail + ' ====')
process.exit(fail > 0 ? 1 : 0)
}
main().catch(e => { console.error('FAILED:', (e && (e.stack || e.message)) || e); process.exit(1) })
+38
View File
@@ -0,0 +1,38 @@
import 'fake-indexeddb/auto'
const BASE = 'http://127.0.0.1:3780'
const store = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => { store.set(k, String(v)) },
removeItem: (k: string) => { store.delete(k) }
}
store.set('dsh_cloud_creds', JSON.stringify({ username: 'goal_smoke', password: 'goal123' }))
store.set('dsh_sync_base', BASE)
async function main() {
const reg = await fetch(BASE + '/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'goal_smoke', password: 'goal123' })
}).catch(() => null)
const schema = await import('../src/db/schema')
const engine = await import('../src/sync/engine')
schema.bindDatabase(new schema.WorklogDB('gs_a'))
const a = schema.db
// 设备 A:建 goal + plan(goal_id 指向本地 goal)
const gid = await a.goal.add({ title: '产品文档学习', kind: '学习', start_date: '2026-10-16', end_date: '2026-10-18', note: null, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
await a.plan.add({ date: '2026-10-16', start_time: '08:00', end_time: '09:00', title: '产品文档学习', kind: '学习', note: null, status: 'todo', actual_minutes: 0, timer_started_at: null, goal_id: gid, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
const rA = await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
console.log('A push:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// 设备 B:空库拉取
schema.bindDatabase(new schema.WorklogDB('gs_b'))
await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
const b = schema.db
const goals = await b.goal.toArray()
const plans = await b.plan.toArray()
const okGoal = goals.length === 1
const okFk = plans.length === 1 && plans[0].goal_id === goals[0].id && plans[0].title === '产品文档学习'
console.log((okGoal ? 'PASS' : 'FAIL') + ' B 拉到长期计划')
console.log((okFk ? 'PASS' : 'FAIL') + ' plan.goal_id 正确水合到本地 goal(' + JSON.stringify({ goalId: plans[0] && plans[0].goal_id, localGoalId: goals[0] && goals[0].id }) + ')')
process.exit(okGoal && okFk ? 0 : 1)
}
main().catch(e => { console.error('FAILED:', (e && (e.stack || e.message)) || e); process.exit(1) })
+145
View File
@@ -0,0 +1,145 @@
// 服务端协议审计:认证 / 九张表收发持久化 / 多用户隔离 / LWW / 墓碑 / SSE / 备份
const BASE = process.env.SYNC_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 ? ' -> ' + String(JSON.stringify(detail)).slice(0, 180) : '')) }
}
async function post(path, body, token) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
body: JSON.stringify(body || {})
})
return { status: r.status, body: await r.json().catch(() => ({})) }
}
const rowsOf = (resp) => resp.body.tables || {}
const tombsOf = (resp) => resp.body.tombstones || []
const row = (_, o) => Object.assign({ uuid: o.uuid, updated_at: o.updated_at || '2026-01-01T00:00:00' }, o)
async function main() {
const reg = await post('/api/auth/register', { username: 'audit_a', password: 'aapass123' })
check('注册成功返回 token', reg.status === 200 && !!reg.body.token, reg.body)
const dup = await post('/api/auth/register', { username: 'audit_a', password: 'other123' })
check('重复用户名被拒 409', dup.status === 409, dup.status)
const bad = await post('/api/auth/login', { username: 'audit_a', password: 'wrong!!' })
check('错误密码登录 401', bad.status === 401, bad.status)
const ok = await post('/api/auth/login', { username: 'audit_a', password: 'aapass123' })
check('正确密码登录 200', ok.status === 200 && !!ok.body.token, ok.status)
let tokA = reg.body.token
const tokA2 = ok.body.token
const noAuth = await post('/api/sync', { tables: {} })
check('无 token 同步被拒 401', noAuth.status === 401, noAuth.status)
const regB = await post('/api/auth/register', { username: 'audit_b', password: 'bbpass123' })
check('第二用户注册成功', regB.status === 200, regB.body)
const all9 = {
project: [row('project', { uuid: 'pu1', name: '项目X', archived: 0 })],
work_log: [row('work_log', { uuid: 'wu1', date: '2026-02-10' })],
log_item: [row('log_item', { uuid: 'iu1', wuuid: 'wu1', puuid: 'pu1', content: '条目1', category: 'dev', minutes: 45, is_milestone: 0, needs_followup: 0, sort_order: 0 })],
attendance: [row('attendance', { uuid: 'au1', date: '2026-02-10', kind: 'normal', clock_in_at: '2026-02-10T09:00:00', clock_out_at: '2026-02-10T18:00:00' })],
weekly_note: [row('weekly_note', { uuid: 'vu1', week_start: '2026-02-09', content: '周记' })],
setting: [row('setting', { uuid: 'su1', key: 'work_start', value: '09:00' })],
memo: [row('memo', { uuid: 'mu1', title: '备忘', content: '内容' })],
credential: [row('credential', { uuid: 'cu1', title: '站点', account: 'user1' })],
transaction: [row('transaction', { uuid: 'tu1', kind: 'expense', amount_cents: 12500, category: 'traffic', happened_on: '2026-02-10' })]
}
const push1 = await post('/api/sync', { tables: all9 }, tokA)
check('九张表推送 200', push1.status === 200, push1.status)
let allEcho = true
for (const t of Object.keys(all9)) {
const got = rowsOf(push1)[t] || []
if (!got.some(x => x.uuid === all9[t][0].uuid)) { allEcho = false; console.log(' 缺表:', t) }
}
check('九张表数据全部回读', allEcho)
const pushEmpty = await post('/api/sync', { tables: {} }, tokA)
let persisted = true
for (const t of Object.keys(all9)) persisted = persisted && (rowsOf(pushEmpty)[t] || []).some(x => x.uuid === all9[t][0].uuid)
check('数据持久化(二次同步仍在)', persisted)
check('回传 serverTime 字段', typeof push1.body.serverTime === 'string')
const bPush = await post('/api/sync', { tables: { project: [row('project', { uuid: 'pb1', name: 'B的私有项目', archived: 0 })] } }, regB.body.token)
check('用户B 可推送自己的数据', bPush.status === 200 && (rowsOf(bPush).project || []).some(x => x.uuid === 'pb1'))
const againA = await post('/api/sync', { tables: {} }, tokA)
check('用户A 看不到 B 的数据', !((rowsOf(againA).project || []).some(x => x.uuid === 'pb1')))
const againB = await post('/api/sync', { tables: {} }, regB.body.token)
check('用户B 空库干净', Object.keys(all9).every(t => !((rowsOf(againB)[t] || []).some(x => x.uuid === all9[t][0].uuid))))
await post('/api/sync', { tables: { setting: [row('setting', { uuid: 'lww-new', key: 'work_end', value: '18:30', updated_at: '2026-03-01T10:00:00' })] } }, tokA)
await post('/api/sync', { tables: { setting: [row('setting', { uuid: 'lww-old', key: 'work_end', value: '17:30', updated_at: '2026-03-01T09:00:00' })] } }, tokA2)
const lwwR = await post('/api/sync', { tables: {} }, tokA)
check('LWW: 保留较新值', (rowsOf(lwwR).setting || []).some(x => x.uuid === 'lww-new' && x.value === '18:30'))
check('LWW: 旧副本被墓碑', tombsOf(lwwR).some(t => t.table === 'setting' && t.uuid === 'lww-old'))
await post('/api/sync', { tables: { setting: [row('setting', { uuid: 'rev-new', key: 'work_start', value: '08:50', updated_at: '2026-03-02T11:00:00' })] } }, tokA2)
const lwwR2 = await post('/api/sync', { tables: {} }, tokA)
check('LWW 反向: 旧 work_start 被墓碑', tombsOf(lwwR2).some(t => t.table === 'setting' && t.uuid === 'su1'))
await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'mt1', title: '待删', content: 'x', updated_at: '2026-04-01T00:00:00' }), row('memo', { uuid: 'ms2', title: '更迟', content: 'y', updated_at: '2030-01-01T00:00:00' })] } }, tokA)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: '2026-04-02T00:00:00' }] }, tokA)
check('墓碑同步后行被清除', !((rowsOf(delPush).memo || []).some(x => x.uuid === 'mt1')))
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1'))
const chg = await post('/api/auth/password', { oldPassword: 'aapass123', newPassword: 'aapass456' }, tokA)
check('修改登录密码 200', chg.status === 200, chg.body)
const oldLogin = await post('/api/auth/login', { username: 'audit_a', password: 'aapass123' })
check('旧密码立即失效 401', oldLogin.status === 401, oldLogin.status)
const newLogin = await post('/api/auth/login', { username: 'audit_a', password: 'aapass456' })
check('新密码登录 200', newLogin.status === 200, newLogin.status)
// 改密后旧令牌立即失效(会话吊销)
const oldTokSync = await post('/api/sync', { tables: {} }, tokA)
check('改密后旧 token 同步被拒 401', oldTokSync.status === 401, oldTokSync.status)
tokA = newLogin.body.token
const chgWrong = await post('/api/auth/password', { oldPassword: 'zzz', newPassword: 'whatever1' }, tokA)
check('错误旧密码改密被拒 400', chgWrong.status === 400, chgWrong.status)
const ac = new AbortController()
const sse = await fetch(BASE + '/api/events?token=' + encodeURIComponent(tokA), { signal: ac.signal })
const reader = sse.body.getReader()
check('SSE 连接 200 + text/event-stream', sse.status === 200 && String(sse.headers.get('content-type')).includes('text/event-stream'))
let changeCount = 0
const readerPromise = (async () => {
const dec = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value)
while (buf.includes('event: change')) { buf = buf.slice(buf.indexOf('event: change') + 13); changeCount++ }
if (changeCount >= 1) return changeCount
}
return changeCount
})()
await new Promise(r => globalThis.setTimeout(r, 300))
await post('/api/sync', { tables: {} }, tokA)
await new Promise(r => globalThis.setTimeout(r, 400))
check('空同步不误广播', changeCount === 0, changeCount)
const trigger = await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'sse-trigger', title: '实时', content: 'sse' })] } }, newLogin.body.token)
check('触发推送 200', trigger.status === 200)
const withEvent = await Promise.race([readerPromise, (async () => { await new Promise(r => globalThis.setTimeout(r, 5000)); return 0 })()])
check('SSE 收到 change 实时事件', withEvent >= 1, withEvent)
ac.abort()
const badSse = await fetch(BASE + '/api/events?token=garbage')
check('伪造 token 的 SSE 被拒 401', badSse.status === 401, badSse.status)
const fsMod = await import('node:fs')
const regJson = JSON.parse(fsMod.readFileSync('server/data/registry.json', 'utf-8'))
const aRec = regJson.users.find(u => u.username === 'audit_a')
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
await new Promise(r => globalThis.setTimeout(r, 400))
check('启动自动备份已生成', fsMod.existsSync('server/data/backups/' + new Date().toISOString().slice(0, 10) + '/registry.json'))
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
const d1 = await post('/api/auth/delete', {}, tokA)
check('删除账号 200', d1.status === 200, d1.body)
const afterDel = await post('/api/auth/login', { username: 'audit_a', password: 'aapass456' })
check('删除后登录被拒 401', afterDel.status === 401, afterDel.status)
check('删除后用户数据文件移除', !fsMod.existsSync('server/data/users/' + reg.body.userId + '.json'))
const b2 = await post('/api/sync', { tables: {} }, regB.body.token)
check('其他用户不受删除影响', b2.status === 200 && (rowsOf(b2).project || []).some(x => x.uuid === 'pb1'))
console.log('==== 服务端协议审计: PASS ' + pass + ' / FAIL ' + fail + ' ====')
process.exit(fail > 0 ? 1 : 0)
}
main().catch(e => { console.error('AUDIT FAILED:', (e && (e.stack || e.message)) || e); process.exit(1) })
+55
View File
@@ -0,0 +1,55 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
async function main() {
bindDatabase(new WorklogDB('repro_db'))
const mod = await import('../src/db/schema')
const db = mod.db
await db.project.bulkPut([{ name: 'P1', archived: 0, created_at: '2025-01-01T00:00:00' }])
await db.work_log.bulkPut([{ date: '2025-01-02', created_at: '2025-01-02T00:00:00' }])
await db.setting.bulkPut([
{ key: 'work_start', value: '09:00' },
{ key: 'sync_base_url', value: 'http://127.0.0.1:3780' }
])
await db.attendance.bulkPut([{ date: '2025-01-02', clock_in_at: '2025-01-02T09:00:00', clock_out_at: '2025-01-02T18:00:00', kind: 'normal', note: null, created_at: '2025-01-02T09:00:00' }])
await db.table('transaction').bulkPut([{ type: 'expense', category: '餐饮', amount: 2500, date: '2025-01-02', note: 'lunch', reimbursable: 1, reimbursed: 0, created_at: '2025-01-02T12:00:00' }])
const engine = await import('../src/sync/engine')
engine.enableRealtimeSync()
await fetch('http://127.0.0.1:3780/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'rt', password: 'rt123456' })
}).catch(() => { /* 已注册则忽略 */ })
const r1 = await engine.runSync({ baseUrl: 'http://127.0.0.1:3780', username: 'rt', password: 'rt123456' })
console.log('SYNC1 OK:', JSON.stringify(r1))
// 触发本地变更,走实时推送路径
await db.table('log_item').add({
work_log_id: 1, content: '实时测试条目', project_id: null, category: 'dev',
minutes: null, is_milestone: 0, needs_followup: 0, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-02T10:00:00'
})
await new Promise(r => setTimeout(r, 4500))
console.log('REALTIME PATH DONE')
// 二次同步幂等验证
const r2 = await engine.runSync({ baseUrl: 'http://127.0.0.1:3780', username: 'rt', password: 'rt123456' })
console.log('SYNC2 OK:', JSON.stringify(r2))
// 记账加载路径验证(与 LedgerView 相同的查询)
const ledger = await import('./db/repo')
const ledgerRows = await ledger.listTransactionsBetween('2026-01-01', '2026-12-31')
console.log('LEDGER LOAD OK:', ledgerRows.length, '条')
const stats = await ledger.listTransactionsBetween('2025-01-01', '2026-12-31')
console.log('LEDGER RANGE OK:', stats.length, '条')
process.exit(0)
}
main().catch(e => {
console.error('FAILED:', (e && (e.stack || e.message)) || e)
process.exit(1)
})
+114
View File
@@ -0,0 +1,114 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
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: 'twodev', password: 'dev123456' })
}).catch(() => { /* 已存在则忽略 */ })
const engine = await import('../src/sync/engine')
// ===== 设备 A:创建项目、工作日志、日志条目、设置 =====
bindDatabase(new WorklogDB('dev_a'))
const a = (await import('../src/db/schema')).db
await a.project.bulkAdd([{ name: '项目A', archived: 0, created_at: '2025-01-01T00:00:00' }])
await a.work_log.bulkAdd([{ date: '2025-01-02', created_at: '2025-01-02T00:00:00' }])
await a.setting.bulkAdd([{ key: 'work_start', value: '09:00' }])
await a.table('log_item').bulkAdd([{
work_log_id: 1, content: 'A 设备的日志', project_id: 1, category: 'dev',
minutes: 60, is_milestone: 1, needs_followup: 0, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-02T10:00:00'
}])
const rA = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备A 推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// ===== 设备 B:全新空库,拉取设备 A 数据 =====
bindDatabase(new WorklogDB('dev_b'))
const b = (await import('../src/db/schema')).db
const rB1 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备B 拉取:', JSON.stringify({ p: rB1.pushed, e: rB1.errors }))
const bProjects = await b.project.toArray()
const bWorkLogs = await b.work_log.toArray()
const bItems = await b.log_item.toArray()
const bSettings = await b.setting.toArray()
check('B 拉到项目', bProjects.length === 1 && bProjects[0].name === '项目A')
check('B 拉到工作日志', bWorkLogs.length === 1 && bWorkLogs[0].date === '2025-01-02')
check('B 拉到日志条目', bItems.length === 1 && bItems[0].content === 'A 设备的日志')
check('B 外键重映射(log_item.work_log_id 指向本地 work_log)', bItems[0].work_log_id === bWorkLogs[0].id)
check('B 外键重映射(log_item.project_id 指向本地 project)', bItems[0].project_id === bProjects[0].id)
check('B 拉到设置', bSettings.some(s => s.key === 'work_start' && s.value === '09:00'))
// ===== 设备 B:打卡(创建当日考勤与工作日志)+ 新日志条目,推送 =====
await b.attendance.bulkAdd([{ date: '2025-01-02', clock_in_at: '2025-01-02T09:00:00', clock_out_at: null, kind: 'normal', note: null, created_at: '2025-01-02T09:00:00' }])
await b.table('log_item').add({
work_log_id: bWorkLogs[0].id, content: 'B 设备的日志', project_id: null, category: 'dev',
minutes: null, is_milestone: 0, needs_followup: 1, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-02T11:00:00'
})
const rB2 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备B 二次推送:', JSON.stringify({ p: rB2.pushed, e: rB2.errors }))
// ===== 设备 A:拉取 B 的数据(考勤 + 新日志条目,含自然键去重场景)=====
bindDatabase(new WorklogDB('dev_a'))
const a2 = (await import('../src/db/schema')).db
const rA2 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备A 拉取:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
const aAtt = await a2.attendance.toArray()
const aItems = await a2.log_item.toArray()
const aWorkLogs = await a2.work_log.toArray()
check('A 拉到 B 的考勤', aAtt.length === 1 && aAtt[0].date === '2025-01-02')
check('A 拉到 B 的日志条目', aItems.some(i => i.content === 'B 设备的日志'))
check('A 无重复工作日志(自然键去重)', aWorkLogs.length === 1)
check('A 的日志条目仍然可见(未因去重丢失)', aItems.length >= 2)
// ===== 冲突场景:两端为同一天各自创建工作日志后合并 =====
bindDatabase(new WorklogDB('dev_b'))
const b2 = (await import('../src/db/schema')).db
await b2.work_log.bulkPut([{ date: '2025-01-03', created_at: '2025-01-03T00:00:00', uuid: 'b-day3', updated_at: '2025-01-03T08:00:00' }])
await b2.table('log_item').add({
work_log_id: 2, content: 'B 的 01-03 记录', project_id: null, category: 'dev',
minutes: null, is_milestone: 0, needs_followup: 0, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-03T08:00:00', uuid: 'item-b-day3', updated_at: '2025-01-03T08:00:00'
})
await b2.table('log_item').add({
work_log_id: 999, content: 'B 的 01-03 记录(重映射目标)', project_id: null, category: 'dev',
minutes: null, is_milestone: 0, needs_followup: 0, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-03T08:30:00', uuid: 'item-b-day3b', updated_at: '2025-01-03T08:30:00', wuuid: 'b-day3'
})
await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
bindDatabase(new WorklogDB('dev_a'))
const a3 = (await import('../src/db/schema')).db
await a3.work_log.bulkAdd([{ date: '2025-01-03', created_at: '2025-01-03T07:00:00', uuid: 'a-day3', updated_at: '2025-01-03T07:00:00' }])
await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
const a3Logs = await a3.work_log.toArray()
const day3 = a3Logs.filter(l => l.date === '2025-01-03')
check('冲突合并后同日仅一条工作日志', day3.length === 1)
console.log(' DEBUG day3:', JSON.stringify(day3))
const a3Items = await a3.log_item.toArray()
const day3Items = a3Items.filter(i => i.content.indexOf('01-03') >= 0)
console.log(' DEBUG day3Items:', JSON.stringify(day3Items))
check('冲突合并后 B 的 01-03 条目保留且外键正确', day3Items.length === 2 && day3Items.every(i => i.work_log_id === day3[0].id))
console.log('==== 结果: PASS ' + pass + ' / FAIL ' + fail + ' ====')
process.exit(fail > 0 ? 1 : 0)
}
main().catch(e => {
console.error('FAILED:', (e && (e.stack || e.message)) || e)
process.exit(1)
})
+126
View File
@@ -0,0 +1,126 @@
// 纯函数单元测试:日期库 / 考勤计算 / 加解密(node:test 运行器,esbuild 打包后执行)
import test from 'node:test'
import assert from 'node:assert/strict'
import { addDays, addMonths, hhmm, isWeekend, nowLocalISO, parseDateStr, toDateStr, tsMinutes, weekStart, formatDateCN } from '../src/lib/date'
import { computeDay } from '../src/domain/attendance'
import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromPassword, pbkdf2Hash, randomSaltB64 } from '../src/lib/crypto'
const RULE = { work_start: '09:00', work_end: '18:00', grace_minutes: 10, lunch_minutes: 60, remind_weekly: 'friday' }
function rec(kind: string, inAt: string | null, outAt: string | null) {
return { date: inAt ? inAt.slice(0, 10) : '2025-01-02', kind, clock_in_at: inAt, clock_out_at: outAt, note: null, location: null, created_at: '2025-01-02T00:00:00' } as never
}
test('日期库:addDays 跨月与闰年', () => {
assert.equal(addDays('2025-01-31', 1), '2025-02-01')
assert.equal(addDays('2024-02-28', 1), '2024-02-29') // 闰年
assert.equal(addDays('2023-02-28', 1), '2023-03-01') // 平年
assert.equal(addDays('2025-03-01', -1), '2025-02-28')
assert.equal(addDays('2024-12-31', 1), '2025-01-01')
})
test('日期库:addMonths 日期截断(1-31 加一个月)', () => {
assert.equal(addMonths('2025-01-31', 1), '2025-02-28')
assert.equal(addMonths('2024-01-31', 1), '2024-02-29')
assert.equal(addMonths('2025-01-15', 1), '2025-02-15')
assert.equal(addMonths('2025-11-30', 2), '2026-01-30')
})
test('日期库:weekStart 以周一为一周开始', () => {
assert.equal(weekStart('2025-01-05'), '2024-12-30') // 周日
assert.equal(weekStart('2025-01-06'), '2025-01-06') // 周一
assert.equal(weekStart('2025-01-12'), '2025-01-06') // 周日
assert.equal(weekStart('2025-01-08'), '2025-01-06') // 周中
})
test('日期库:isWeekend 与格式化', () => {
assert.equal(isWeekend('2025-01-04'), true)
assert.equal(isWeekend('2025-01-05'), true)
assert.equal(isWeekend('2025-01-06'), false)
assert.equal(hhmm('2025-01-02T09:05:00'), '09:05')
assert.equal(hhmm(null), '--:--')
assert.match(nowLocalISO(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
assert.equal(formatDateCN('2025-01-06'), '2025 年 1 月 6 日 周一')
assert.equal(toDateStr(parseDateStr('2025-02-27')), '2025-02-27')
assert.equal(tsMinutes('2025-01-02T09:00:00', '2025-01-02T18:00:00'), 540)
})
test('考勤:正常/迟到/早退/宽限边界', () => {
const day = '2025-01-02' // 周四
const normal = computeDay(rec('normal', day + 'T09:00:00', day + 'T18:00:00'), day, RULE, false)
assert.equal(normal.status, 'normal')
assert.equal(normal.label, '正常')
assert.equal(normal.workedMinutes, 480) // 9 小时 - 1 小时午休
// 宽限边界:09:10 恰好等于上班时间+10 分钟 → 不算迟到
const edge = computeDay(rec('normal', day + 'T09:10:00', day + 'T18:00:00'), day, RULE, false)
assert.equal(edge.status, 'normal')
// 迟到:09:11
const late = computeDay(rec('normal', day + 'T09:11:00', day + 'T18:00:00'), day, RULE, false)
assert.equal(late.status, 'late')
// 早退:17:29 下班
const early = computeDay(rec('normal', day + 'T09:00:00', day + 'T17:29:00'), day, RULE, false)
assert.equal(early.status, 'early')
// 补卡不判迟到早退
const makeup = computeDay(rec('makeup', day + 'T10:30:00', day + 'T17:00:00'), day, RULE, false)
assert.equal(makeup.status, 'normal')
assert.equal(makeup.label, '补卡')
assert.equal(makeup.makeup, true)
})
test('考勤:进行中/未完成/缺勤/未打卡', () => {
const day = '2025-01-02'
const running = computeDay(rec('normal', day + 'T09:00:00', null), day, RULE, true)
assert.equal(running.status, 'incomplete')
assert.equal(running.label, '进行中')
const unfinished = computeDay(rec('normal', day + 'T09:00:00', null), day, RULE, false)
assert.equal(unfinished.status, 'incomplete')
assert.equal(unfinished.label, '未完成')
const absent = computeDay(undefined, day, RULE, false)
assert.equal(absent.status, 'absent')
const none = computeDay(undefined, day, RULE, true)
assert.equal(none.status, 'none')
})
test('考勤:休假与休息日', () => {
const saturday = '2025-01-04'
const leave = computeDay(rec('leave', '2025-01-02T09:00:00', '2025-01-02T18:00:00'), '2025-01-02', RULE, false)
assert.equal(leave.status, 'leave')
assert.equal(leave.label, '请假')
// 休息日:即使有打卡记录也显示休息日(当前产品行为,锁定语义)
const rest = computeDay(rec('normal', saturday + 'T10:00:00', saturday + 'T18:00:00'), saturday, RULE, false)
assert.equal(rest.status, 'rest')
assert.equal(rest.label, '休息日')
// 休息日无记录
const restEmpty = computeDay(undefined, saturday, RULE, false)
assert.equal(restEmpty.status, 'rest')
})
test('考勤:异常时长不出现负数', () => {
const bad = computeDay(rec('normal', '2025-01-02T18:00:00', '2025-01-02T09:00:00'), '2025-01-02', RULE, false)
assert.equal(bad.workedMinutes, 0)
})
test('加密:哈希确定性、加盐、AES-GCM 往返', async () => {
// 盐必须是合法 base64(与 randomSaltB64 的产出格式一致)
const saltA = Buffer.from('salt-a').toString('base64')
const saltB = Buffer.from('salt-b').toString('base64')
const h1 = await pbkdf2Hash('密码abc123', saltA)
const h2 = await pbkdf2Hash('密码abc123', saltA)
const h3 = await pbkdf2Hash('密码abc123', saltB)
const h4 = await pbkdf2Hash('密码abc124', saltA)
assert.equal(h1, h2)
assert.notEqual(h1, h3)
assert.notEqual(h1, h4)
assert.equal(Buffer.from(h1, 'base64').length, 32)
const salt = randomSaltB64()
assert.equal(Buffer.from(salt, 'base64').length, 16)
const key = await deriveKeyFromPassword('主密码!@#', salt)
const enc = await aesGcmEncrypt(key, '机密内容:服务器管理员密码')
assert.notEqual(enc, '机密内容:服务器管理员密码')
assert.equal(await aesGcmDecrypt(key, enc), '机密内容:服务器管理员密码')
const otherKey = await deriveKeyFromPassword('错误主密码', salt)
await assert.rejects(() => aesGcmDecrypt(otherKey, enc))
})
+426 -91
View File
@@ -3,41 +3,39 @@ import { computed, onMounted, ref, watch } from 'vue'
import Icon from './components/Icon.vue'
import CalendarView from './views/CalendarView.vue'
import TodayView from './views/TodayView.vue'
import PlanView from './views/PlanView.vue'
import StatsView from './views/StatsView.vue'
import ReportView from './views/ReportView.vue'
import LedgerView from './views/LedgerView.vue'
import MemoView from './views/MemoView.vue'
import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
import UserAdminView from './views/UserAdminView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } from './sync/engine'
import { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
import { addDays, hhmm, todayStr, weekStart } from './lib/date'
import { useToasts } from './lib/toast'
import { showToast, useToasts } from './lib/toast'
import { attendanceChanged, focusDate, focusMemo, notifyAttendanceChanged } from './lib/bus'
import { categoryLabel, type Attendance, type LogItem, type Memo } from './db/schema'
const toasts = useToasts()
// ---- 用户会话 ----
// ---- 用户会话(持久化登录,退出走顶栏按钮)----
const session = ref<Session | null>(getSession())
const IDLE_LOGOUT_MS = 30 * 60 * 1000
let sessionIdleTimer: number | null = null
function armSessionTimer() {
if (sessionIdleTimer !== null) window.clearTimeout(sessionIdleTimer)
if (!session.value) return
sessionIdleTimer = window.setTimeout(() => { doLogout(true) }, IDLE_LOGOUT_MS)
}
function onActivity() {
if (session.value) armSessionTimer()
}
async function onLogin(s: Session) {
session.value = s
openUserDatabase(s.userId)
location.hash = '#/calendar'
route.value = 'calendar'
armSessionTimer()
await refreshClock()
await refreshTip()
startSyncTimer()
try {
await ensureCloudBinding()
await autoSyncNow()
} catch { /* 云端不可达时不阻塞登录,稍后自动重试 */ }
showToast('欢迎,' + s.username)
const mt = sessionStorage.getItem('dsh_migrate_toast')
if (mt) {
@@ -45,12 +43,25 @@ async function onLogin(s: Session) {
showToast('已迁移原有数据 ' + mt + ' 条')
}
}
async function doLogout(auto: boolean) {
async function doLogout() {
mobileSearch.value = false
moreOpen.value = false
stopRealtime()
if (syncTimer !== null) { window.clearInterval(syncTimer); syncTimer = null }
await logoutUser()
session.value = null
route.value = 'calendar'
location.hash = '#/calendar'
showToast(auto ? '长时间未操作已自动退出' : '已退出登录')
showToast('已退出登录')
}
// ---- 实时同步:本地变更 3 秒推送 + SSE 秒级拉取 + 60 秒兜底 ----
let syncTimer: number | null = null
function startSyncTimer() {
void enableRealtimeSync()
if (syncTimer !== null) window.clearInterval(syncTimer)
syncTimer = window.setInterval(() => { void autoSyncNow() }, 60 * 1000)
void autoSyncNow()
}
// ---- 全局搜索 ----
@@ -100,24 +111,55 @@ function onGlobalKey(e: KeyboardEvent) {
}
const VIEWS = [
{ key: 'calendar', name: '日历', icon: 'calendar' },
{ key: 'today', name: '今日', icon: 'edit' },
{ key: 'stats', name: '统计', icon: 'chart' },
{ key: 'report', name: '总结', icon: 'export' },
{ key: 'memo', name: '备忘录', icon: 'memo' },
{ key: 'vault', name: '密码库', icon: 'lock' },
{ key: 'settings', name: '设置', icon: 'settings' }
{ key: 'calendar', name: '日历', icon: 'calendar', group: '记录' },
{ key: 'today', name: '详情', icon: 'edit', group: '记录' },
{ key: 'plan', name: '计划', icon: 'plan', group: '记录' },
{ key: 'ledger', name: '记账', icon: 'wallet', group: '工具' },
{ key: 'memo', name: '备忘录', icon: 'memo', group: '工具' },
{ key: 'stats', name: '统计', icon: 'chart', group: '分析' },
{ key: 'report', name: '总结', icon: 'export', group: '分析' },
{ key: 'vault', name: '密码库', icon: 'lock', group: '系统' },
{ key: 'settings', name: '设置', icon: 'settings', group: '系统' },
{ key: 'admin', name: '用户管理', icon: 'shield', group: '系统' }
]
function readRoute(): string {
const h = (location.hash || '').replace('#/', '')
return VIEWS.some(v => v.key === h) ? h : 'calendar'
if (!VIEWS.some(v => v.key === h)) return 'calendar'
if (h === 'admin' && (getSession()?.username || '') !== 'xionglin') return 'calendar'
return h
}
const route = ref(readRoute())
window.addEventListener('hashchange', () => { route.value = readRoute() })
function nav(key: string) { location.hash = '#/' + key }
const current = computed(() => VIEWS.find(v => v.key === route.value) || VIEWS[0])
// ---- 用户管理:仅管理员 xionglin 可见 ----
const isAdminUser = computed(() => (session.value?.username || '') === 'xionglin')
const shownViews = computed(() => isAdminUser.value ? VIEWS : VIEWS.filter(v => v.key !== 'admin'))
const moreViews = computed(() => {
const list = VIEWS.filter(v => !TAB_KEYS.includes(v.key))
return isAdminUser.value ? list : list.filter(v => v.key !== 'admin')
})
const isMoreActive = computed(() => moreViews.value.some(v => v.key === route.value))
watch([isAdminUser, route], () => {
if (route.value === 'admin' && !isAdminUser.value) nav('calendar')
}, { immediate: true })
// 手机端底部标签页:4 个主功能 + 「更多」弹层(统计/总结/密码库/设置/退出)
const TAB_KEYS = ['calendar', 'today', 'plan', 'ledger']
const TABS = VIEWS.filter(v => TAB_KEYS.includes(v.key))
const moreOpen = ref(false)
function navFromSheet(key: string) { moreOpen.value = false; nav(key) }
// 手机端全屏搜索
const mobileSearch = ref(false)
const msInput = ref<HTMLInputElement | null>(null)
function openMobileSearch() { mobileSearch.value = true; window.setTimeout(() => msInput.value?.focus(), 60) }
function pickLog(item: LogItem & { date: string }) { openLog(item); mobileSearch.value = false }
function pickMemo(m: Memo) { openMemo(m); mobileSearch.value = false }
// ---- 工具栏打卡 ----
const today = ref(todayStr())
const att = ref<Attendance | undefined>(undefined)
@@ -180,31 +222,46 @@ async function refreshTip() {
onMounted(async () => {
window.addEventListener('keydown', onGlobalKey)
window.addEventListener('click', onActivity)
if (session.value) {
openUserDatabase(session.value.userId)
armSessionTimer()
await refreshClock()
await refreshTip()
startSyncTimer()
}
})
</script>
<template>
<LoginView v-if="!session" @login="onLogin" />
<div v-else class="app">
<!-- 桌面侧栏:品牌 + 分组导航 + 当前用户 -->
<nav class="side">
<button
v-for="v in VIEWS" :key="v.key"
class="nav-btn" :class="{ active: route === v.key }"
:data-tip="v.name" @click="nav(v.key)"
>
<Icon :name="v.icon" :size="18" />
</button>
<div class="brand" @click="nav('calendar')">
<span class="brand-mark"><Icon name="calendar" :size="15" /></span>
<span class="brand-name">实习工作日志</span>
</div>
<div class="side-nav">
<template v-for="(v, i) in shownViews" :key="v.key">
<div v-if="i === 0 || v.group !== shownViews[i - 1].group" class="nav-group">{{ v.group }}</div>
<button class="nav-btn" :class="{ active: route === v.key }" :data-tip="v.name" @click="nav(v.key)">
<Icon :name="v.icon" :size="17" />
<span class="nav-label">{{ v.name }}</span>
</button>
</template>
</div>
<div class="side-foot">
<span class="avatar">{{ (session?.username || '用户').slice(0, 1).toUpperCase() }}</span>
<span class="foot-name">{{ session?.username }}</span>
<button class="icon-btn" title="退出登录" @click="doLogout">
<Icon name="export" :size="15" />
</button>
</div>
</nav>
<div class="main">
<header class="topbar">
<span class="title">{{ current.name }}</span>
<button class="mob-search-btn" title="搜索" @click="openMobileSearch">
<Icon name="search" :size="16" />
</button>
<div class="gsearch">
<Icon name="search" :size="14" class="gs-icon" />
<input
@@ -230,28 +287,97 @@ onMounted(async () => {
</div>
</div>
<span class="flex1"></span>
<button v-if="weekTip" class="week-tip" @click="nav('report')">
<span class="dot" style="background: var(--warn)"></span>
填写本周周记
</button>
<span class="user-chip num">{{ session?.username }}</span>
<button class="btn btn-sm" @click="doLogout(false)">退出</button>
<button class="btn btn-primary btn-sm" :disabled="clockDone" @click="doClock">
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
<span class="num">{{ clockLabel }}</span>
</button>
<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>
<main class="content">
<div class="content-inner">
<CalendarView v-if="route === 'calendar'" />
<TodayView v-else-if="route === 'today'" />
<PlanView v-else-if="route === 'plan'" />
<StatsView v-else-if="route === 'stats'" />
<ReportView v-else-if="route === 'report'" />
<LedgerView v-else-if="route === 'ledger'" />
<MemoView v-else-if="route === 'memo'" />
<VaultView v-else-if="route === 'vault'" />
<UserAdminView v-else-if="route === 'admin'" />
<SettingsView v-else />
</div>
</main>
<!-- 手机端底部标签栏 -->
<nav class="tabbar">
<button
v-for="v in TABS" :key="v.key"
class="tab-btn" :class="{ active: route === v.key }"
@click="nav(v.key)"
>
<Icon :name="v.icon" :size="20" />
<span>{{ v.name }}</span>
</button>
<button class="tab-btn" :class="{ active: isMoreActive }" @click="moreOpen = true">
<Icon name="more" :size="20" />
<span>更多</span>
</button>
</nav>
<!-- 手机端「更多」底部弹层 -->
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
<div class="more-sheet" @click.stop>
<div class="sheet-user num">{{ session?.username }}</div>
<button v-for="v in moreViews" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
<Icon :name="v.icon" :size="18" />
<span class="sheet-name">{{ v.name }}</span>
<Icon name="chevron-right" :size="14" class="sheet-go" />
</button>
<div class="sheet-divider"></div>
<button class="sheet-row sheet-danger" @click="moreOpen = false; doLogout()">
<Icon name="close" :size="18" />
<span class="sheet-name">退出登录</span>
</button>
</div>
</div>
<!-- 手机端全屏搜索 -->
<div v-if="mobileSearch" class="msw-mask" @click.self="mobileSearch = false">
<div class="msw">
<div class="msw-bar">
<Icon name="search" :size="16" class="msw-icon" />
<input
ref="msInput" class="msw-input" v-model="searchQ"
placeholder="搜索工作记录与备忘录"
@input="onSearchInput" @keydown.esc="mobileSearch = false"
/>
<button class="icon-btn" @click="clearSearch(); mobileSearch = false">
<Icon name="close" :size="16" />
</button>
</div>
<div class="msw-results">
<template v-if="hasResults">
<div v-if="results.logs.length" class="gs-group muted">工作记录</div>
<div v-for="r in results.logs" :key="'l' + r.id" class="msw-item" @mousedown.prevent="pickLog(r)">
<span class="tag">{{ categoryLabel(r.category) }}</span>
<span class="gs-text">{{ r.content }}</span>
<span class="muted num gs-date">{{ r.date }}</span>
</div>
<div v-if="results.memos.length" class="gs-group muted">备忘录</div>
<div v-for="m in results.memos" :key="'m' + m.id" class="msw-item" @mousedown.prevent="pickMemo(m)">
<span class="gs-text">{{ m.title || '无标题' }}</span>
<span class="muted gs-sub">{{ m.content }}</span>
</div>
</template>
<div v-else-if="searchQ.trim()" class="gs-empty muted">无匹配结果</div>
</div>
</div>
</div>
</div>
<div class="toast-wrap">
<transition-group name="toast">
@@ -260,40 +386,73 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.app { display: flex; height: 100vh; overflow: hidden; }
.app { display: flex; height: 100vh; height: 100dvh; overflow: hidden; background: var(--bg-app); }
/* ---------- 侧栏 ---------- */
.side {
width: 56px;
width: var(--side-w);
flex: none;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10px;
gap: 4px;
background: var(--bg-page);
border-right: 1px solid var(--border);
}
.brand {
display: flex; align-items: center; gap: 9px;
height: var(--topbar-h); flex: none;
padding: 0 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.brand-mark {
width: 26px; height: 26px; flex: none;
border-radius: 7px;
background: var(--accent);
color: #FFFFFF;
display: flex; align-items: center; justify-content: center;
}
.brand-name { font-size: 14px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap; }
.side-nav {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-group {
font-size: 11px;
color: var(--text-3);
padding: 12px 8px 4px;
letter-spacing: 2px;
white-space: nowrap;
}
.nav-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-3);
display: flex; align-items: center; gap: 10px;
height: 36px; padding: 0 10px;
border: none; background: transparent;
border-radius: var(--radius-sm);
color: var(--text-2);
cursor: pointer;
border: none;
background: transparent;
font-size: 13px;
position: relative;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.nav-btn.active { background: var(--accent-bg); color: var(--accent); }
.nav-btn.active { background: var(--accent-bg); color: var(--accent); font-weight: 500; }
.nav-btn.active::before {
content: '';
position: absolute; left: 0; top: 8px; bottom: 8px;
width: 3px; border-radius: 2px;
background: var(--accent);
}
.nav-label { white-space: nowrap; }
.nav-btn::after {
content: attr(data-tip);
position: absolute;
left: 46px;
left: 50px;
top: 50%;
transform: translateY(-50%);
background: var(--text-1);
@@ -306,27 +465,57 @@ onMounted(async () => {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 40;
}
.side-foot {
flex: none;
display: flex; align-items: center; gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
}
.avatar {
width: 28px; height: 28px; flex: none;
border-radius: 50%;
background: var(--accent-bg);
color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600;
}
.foot-name {
flex: 1; min-width: 0;
font-size: 13px; color: var(--text-2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav-btn:hover::after { opacity: 1; }
/* ---------- 主区 ---------- */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
height: 56px;
height: var(--topbar-h);
flex: none;
background: var(--bg-page);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
z-index: 10;
}
.title { font-size: 14px; font-weight: 600; }
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
.gsearch { position: relative; width: 300px; max-width: 38vw; }
.gs-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
.gs-input { height: 30px; padding-left: 30px; }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
.gsearch { position: relative; width: 320px; max-width: 38vw; }
.gs-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
.gs-input {
height: 32px; padding-left: 31px;
border-radius: 999px;
background: var(--bg-subtle);
border-color: transparent;
}
.gs-input:focus { background: var(--bg-page); border-color: var(--accent); }
.gs-panel {
position: absolute;
top: 36px;
top: 38px;
left: 0;
right: 0;
background: var(--bg-page);
@@ -343,7 +532,7 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
padding: 7px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
@@ -360,12 +549,6 @@ onMounted(async () => {
.gs-date { font-size: 12px; flex: none; }
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
.user-chip {
font-size: 12px;
color: var(--text-2);
padding: 0 4px;
}
.week-tip {
display: inline-flex;
align-items: center;
@@ -374,15 +557,16 @@ onMounted(async () => {
color: var(--warn);
background: var(--bg-page);
border-radius: 999px;
height: 26px;
height: 28px;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.week-tip:hover { background: var(--bg-subtle); }
.week-tip:hover { background: #FDF4EC; }
.content { flex: 1; overflow: auto; }
.content-inner { max-width: 1200px; margin: 0 auto; padding: 20px; }
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
.toast-wrap {
position: fixed;
@@ -407,19 +591,170 @@ onMounted(async () => {
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-4px); }
.toast-enter-active, .toast-leave-active { transition: all 0.15s ease; }
/* ---------- 手机端组件 ---------- */
.mob-search-btn {
display: none;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--text-2);
cursor: pointer;
align-items: center;
justify-content: center;
}
.tabbar {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
background: var(--bg-page);
border-top: 1px solid var(--border);
padding-bottom: env(safe-area-inset-bottom);
}
.tab-btn {
flex: 1;
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
font-size: 11px;
}
.tab-btn.active { color: var(--accent); font-weight: 500; }
.sheet-mask {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(29, 33, 41, 0.4);
}
.more-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-page);
border-radius: 16px 16px 0 0;
padding: 10px 14px calc(14px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.sheet-user {
font-size: 12px;
color: var(--text-3);
padding: 4px 6px 10px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.sheet-row {
display: flex;
align-items: center;
gap: 12px;
height: 46px;
border: none;
background: transparent;
color: var(--text-1);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-sm);
text-align: left;
padding: 0 6px;
}
.sheet-row:hover, .sheet-row:active { background: var(--bg-hover); }
.sheet-name { flex: 1; }
.sheet-go { color: var(--text-3); }
.sheet-divider { height: 1px; background: var(--border); margin: 6px 0; }
.sheet-danger { color: var(--danger); }
.msw-mask { position: fixed; inset: 0; z-index: 70; background: var(--bg-page); }
.msw { height: 100%; display: flex; flex-direction: column; }
.msw-bar {
height: 52px;
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--border);
}
.msw-icon { color: var(--text-3); }
.msw-input {
flex: 1;
height: 34px;
border: none;
outline: none;
font-size: 14px;
background: transparent;
color: var(--text-1);
}
.msw-results {
flex: 1;
overflow: auto;
padding: 10px 4px calc(20px + env(safe-area-inset-bottom));
}
.msw-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.msw-item:hover, .msw-item:active { background: var(--bg-hover); }
/* ---------- 平板(721-1000px):侧栏折叠为图标 ---------- */
@media (max-width: 1000px) and (min-width: 721px) {
.side { width: var(--side-w-collapsed); }
.brand { justify-content: center; padding: 0; }
.brand-name, .nav-label, .nav-group { display: none; }
.nav-btn { justify-content: center; padding: 0; width: 40px; align-self: center; }
.nav-btn.active::before { display: none; }
.side-foot { flex-direction: column; gap: 6px; }
.foot-name { display: none; }
}
/* ---------- 手机端(<=720px) ---------- */
@media (max-width: 720px) {
.app { flex-direction: column; }
.side {
order: 2;
width: 100%;
height: 56px;
flex-direction: row;
justify-content: center;
border-right: none;
border-top: 1px solid var(--border);
padding-top: 0;
.app { display: block; height: 100dvh; }
.side { display: none; }
.main { display: flex; flex-direction: column; height: 100dvh; min-width: 0; }
.topbar {
height: 50px;
min-height: 50px;
padding: 0 10px;
gap: 6px;
}
.title { font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gsearch { display: none; }
.gsearch .gs-panel { display: none; }
.mob-search-btn { display: flex; }
.topbar-actions { margin-left: auto; gap: 6px; }
.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; }
.content { flex: 1; min-height: 0; }
.content-inner {
max-width: none;
padding: 12px 12px calc(70px + env(safe-area-inset-bottom));
}
.toast-wrap {
top: 6px;
right: 10px;
left: 10px;
align-items: flex-start;
}
.main { order: 1; height: calc(100vh - 56px); }
.nav-btn::after { display: none; }
}
</style>
+2 -24
View File
@@ -41,29 +41,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
width: 380px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
padding: 20px;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog { width: 380px; }
.dialog-msg { margin-top: 8px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
.dialog-btns { margin-top: 20px; display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
.btn-danger-solid:hover { background: #D13A3F; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.dialog-btns { margin-top: 20px; }
</style>
+1 -38
View File
@@ -112,44 +112,7 @@ function save() {
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 480px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.dialog { width: 480px; }
.pwd-row { display: flex; gap: 8px; }
.pwd-row .input { flex: 1; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { getLedgerCategories, setLedgerCategories } from '../db/categories'
const props = defineProps<{
open: boolean
title: string
initial: { type: string; category: string; amountYuan: string; date: string; note: string; reimbursable: number; reimbursed: number }
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'save', payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }): void
}>()
const fType = ref<'expense' | 'income'>('expense')
const fCategory = ref('')
const fAmount = ref('')
const fDate = ref('')
const fNote = ref('')
const fReimbursable = ref(false)
const fReimbursed = ref(false)
const fError = ref('')
const amountInput = 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) {
await loadCats()
fType.value = (props.initial.type === 'income' ? 'income' : 'expense')
fCategory.value = props.initial.category
fAmount.value = props.initial.amountYuan
fDate.value = props.initial.date
fNote.value = props.initial.note
fReimbursable.value = props.initial.reimbursable === 1
fReimbursed.value = props.initial.reimbursed === 1
fError.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
amountInput.value?.focus()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
// 切换收支类型后:重新拉取对应分类列表,并回落到第一项
watch(fType, async () => {
await loadCats()
fCategory.value = categoryOptions.value[0] || ''
})
const categoryOptions = ref<string[]>([])
async function loadCats() {
categoryOptions.value = await getLedgerCategories(fType.value)
}
const newCat = ref('')
async function addCategory() {
const name = newCat.value.trim()
if (!name) return
if (categoryOptions.value.includes(name)) { newCat.value = ''; return }
categoryOptions.value.push(name)
await setLedgerCategories(fType.value, categoryOptions.value)
fCategory.value = name
newCat.value = ''
}
function computedCategory() {
return (fCategory.value && categoryOptions.value.includes(fCategory.value)) ? fCategory.value : categoryOptions.value[0]
}
function save() {
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
}
const reimbursable = fType.value === 'expense' && fReimbursable.value ? 1 : 0
emit('save', {
type: fType.value,
category: computedCategory(),
amount: Math.round(v * 100),
date: fDate.value,
note: String(fNote.value ?? '').trim(),
reimbursable,
reimbursed: reimbursable && fReimbursed.value ? 1 : 0
})
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-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="form-grid">
<div class="field span2">
<label>类型</label>
<div class="seg">
<button class="seg-btn" :class="{ active: fType === 'expense' }" @click="fType = 'expense'">支出</button>
<button class="seg-btn" :class="{ active: fType === 'income' }" @click="fType = 'income'">收入</button>
</div>
</div>
<div class="field">
<label>金额(元,必填)</label>
<input ref="amountInput" class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" />
</div>
<div class="field">
<label>日期(必填)</label>
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类(可直接新增)</label>
<select class="select" v-model="fCategory">
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
<div class="add-cat">
<input class="input add-cat-input" v-model="newCat" placeholder="新增分类名" @keydown.enter.prevent="addCategory" />
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
</div>
</div>
<div class="field" v-if="fType === 'expense'">
<label>报销状态</label>
<div class="seg">
<button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button>
<button class="seg-btn" :class="{ active: fReimbursable && !fReimbursed }" @click="fReimbursable = true; fReimbursed = false">待报销</button>
<button class="seg-btn" :class="{ active: fReimbursable && fReimbursed }" @click="fReimbursable = true; fReimbursed = true">已报销</button>
</div>
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="fNote" placeholder="选填"></textarea>
</div>
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.dialog { width: 480px; }
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
</style>
+5 -1
View File
@@ -21,5 +21,9 @@ export const ICONS: Record<string, string> = {
search: svg('<circle cx="11" cy="11" r="6.5"/><path d="M15.8 15.8L20 20"/>'),
settings: svg('<circle cx="12" cy="12" r="3"/><path d="M12 4.5v2.2M12 17.3v2.2M4.5 12h2.2M17.3 12h2.2M6.8 6.8l1.6 1.6M15.6 15.6l1.6 1.6M17.2 6.8l-1.6 1.6M8.4 15.6l-1.6 1.6"/>'),
memo: svg('<path d="M6.5 3.5h7.5L18.5 8v11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M14 3.5V8h4.5M9.5 12h5M9.5 15.5h5"/>'),
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>')
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
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"/>')
}
+45 -129
View File
@@ -8,13 +8,16 @@ import {
getLocationPresets, getWorkRule, listLogItemsByDate, listProjects, saveMakeup,
setAttendanceLocation, setAttendanceTime, setLeave, updateLogItem
} from '../../db/repo'
import { CATEGORIES, categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { getLogCategories } from '../../db/categories'
import { formatDateCN, hhmm, todayStr } from '../../lib/date'
import { computeDay, type DayAttendanceInfo } from '../../domain/attendance'
import type { WorkRule } from '../../db/repo'
import { showToast } from '../../lib/toast'
import { attendanceChanged, notifyAttendanceChanged } from '../../lib/bus'
const logCats = ref<string[]>([])
getLogCategories().then(l => { logCats.value = l })
const props = defineProps<{ date: string }>()
const loading = ref(true)
@@ -158,60 +161,18 @@ async function saveMakeupForm() {
showToast('补卡已保存')
}
// ---- 新增工作记录 ----
const content = ref('')
const contentError = ref('')
watch(content, () => {
if (contentError.value) contentError.value = ''
})
const projectId = ref<number | null>(null)
const category = ref('dev')
const minutesVal = ref<number | null>(null)
const takeawayVal = ref('')
const remarkVal = ref('')
const submitting = ref(false)
const formDirty = computed(() => !!(content.value.trim() || minutesVal.value || takeawayVal.value.trim() || remarkVal.value.trim()))
onMounted(async () => {
const lp = await getLastPick()
projectId.value = lp.project_id
category.value = lp.category
})
function resetForm() {
content.value = ''
minutesVal.value = null
takeawayVal.value = ''
remarkVal.value = ''
contentError.value = ''
}
async function submit() {
const text = content.value.trim()
if (!text) {
contentError.value = '请填写工作内容'
return
}
if (submitting.value) return
submitting.value = true
await addLogItem(props.date, {
content: text,
project_id: projectId.value,
category: category.value,
minutes: minutesVal.value && minutesVal.value > 0 ? minutesVal.value : null,
takeaway: takeawayVal.value.trim() || null,
remark: remarkVal.value.trim() || null
})
resetForm()
await load()
showToast('记录已添加')
submitting.value = false
}
// ---- 条目操作 ----
// 弹窗开关与编辑目标分离:添加模式 editTarget 为 null,但弹窗也要打开
const editTarget = ref<LogItem | null>(null)
const dialogOpen = ref(false)
function openEdit(it: LogItem) {
editTarget.value = it
dialogOpen.value = true
}
function openAdd() {
editTarget.value = null
dialogOpen.value = true
}
async function onEditSave(payload: {
id: number
@@ -224,21 +185,23 @@ async function onEditSave(payload: {
is_milestone: number
needs_followup: number
}) {
const id = payload.id
const patch = {
content: payload.content,
project_id: payload.project_id,
category: payload.category,
minutes: payload.minutes,
takeaway: payload.takeaway,
remark: payload.remark,
is_milestone: payload.is_milestone,
needs_followup: payload.needs_followup
if (payload.id) {
await updateLogItem(payload.id, {
content: payload.content, project_id: payload.project_id, category: payload.category,
minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark,
is_milestone: payload.is_milestone, needs_followup: payload.needs_followup
})
showToast('记录已更新')
} else {
await addLogItem(props.date, {
content: payload.content, project_id: payload.project_id, category: payload.category,
minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark
})
showToast('记录已添加')
}
await updateLogItem(id, patch)
editTarget.value = null
dialogOpen.value = false
await load()
showToast('修改已保存')
}
async function toggleMilestone(it: LogItem) {
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
@@ -378,54 +341,11 @@ async function confirmDelete() {
</div>
</div>
<div class="card sec">
<div class="sec-head">
<span class="sec-title">新增工作记录</span>
<span class="muted form-hint">内容必填,其余选填</span>
</div>
<div class="form-grid">
<div class="field span2">
<label>工作内容(必填)</label>
<input
class="input" :class="{ invalid: contentError }"
v-model="content" placeholder="做了什么,一句话一条" @keydown.enter="submit"
/>
<div v-if="contentError" class="form-error">{{ contentError }}</div>
</div>
<div class="field">
<label>所属项目</label>
<select class="select" v-model="projectId">
<option :value="null">无项目</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="field">
<label>工作分类</label>
<select class="select" v-model="category">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="minutesVal" placeholder="选填" />
</div>
<div class="field">
<label>主要收获</label>
<input class="input" v-model="takeawayVal" placeholder="一句话:学到了什么" />
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="remarkVal" placeholder="背景、链接、未决事项(选填)"></textarea>
</div>
</div>
<div class="form-footer">
<button class="btn btn-sm" :disabled="!formDirty" @click="resetForm">重置</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="submit">添加记录</button>
</div>
</div>
<div class="list-sec">
<div class="sec-title">工作记录({{ items.length }})</div>
<div class="sec-head">
<span class="sec-title">工作记录({{ items.length }})</span>
<button class="btn btn-primary btn-sm" @click="openAdd"><Icon name="plus" :size="14" /> 添加记录</button>
</div>
<div v-if="!items.length && !loading" class="empty muted">暂无记录</div>
<div v-for="it in items" :key="it.id" class="item">
<div class="item-main">
@@ -483,11 +403,11 @@ async function confirmDelete() {
/>
<EditLogDialog
:open="!!editTarget"
:open="dialogOpen"
:item="editTarget"
:projects="projectsAll"
:date="date"
@cancel="editTarget = null"
@cancel="dialogOpen = false"
@save="onEditSave"
/>
</div>
@@ -497,34 +417,32 @@ async function confirmDelete() {
.dp-head { margin-bottom: 12px; }
.dp-date { font-size: 14px; font-weight: 600; }
.sec { padding: 14px 16px; margin-bottom: 12px; }
.sec-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-1); }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.form-hint { font-size: 12px; }
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.att-cell label, .field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.att-cell label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.att-val { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 13px; }
.status-val { min-height: 32px; }
.val-empty { color: var(--text-3); }
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.time-input { width: 104px; }
.att-note { margin-top: 10px; font-size: 12px; }
.att-actions { display: flex; gap: 8px; margin-top: 12px; }
.att-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.form-footer {
display: flex; justify-content: flex-end; gap: 8px;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
}
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid { border-color: var(--danger); }
.input.invalid:focus { border-color: var(--danger); }
.list-sec { margin-bottom: 4px; }
.item { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 6px; background: var(--bg-page); }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.item {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 9px 12px;
margin-bottom: 6px;
background: var(--bg-subtle);
transition: border-color 0.12s, background 0.12s;
}
.item:hover { border-color: var(--border-strong); background: var(--bg-page); }
.item-main { display: flex; align-items: center; gap: 8px; min-height: 26px; flex-wrap: wrap; }
.item-content { cursor: text; word-break: break-all; }
.item-meta { font-size: 12px; }
@@ -532,11 +450,9 @@ async function confirmDelete() {
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
font-size: 12px; display: flex; flex-direction: column; gap: 2px;
}
.empty { text-align: center; padding: 24px 0; }
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) {
.att-grid, .form-grid { grid-template-columns: 1fr; }
.field.span2 { grid-column: auto; }
.att-grid { grid-template-columns: 1fr; }
}
</style>
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import { CATEGORIES, type LogItem, type Project } from '../../db/schema'
import { type LogItem, type Project } from '../../db/schema'
import { getLogCategories } from '../../db/categories'
const logCats = ref<string[]>([])
getLogCategories().then(l => { logCats.value = l })
const props = defineProps<{
open: boolean
item: LogItem | null
@@ -86,7 +89,7 @@ function save() {
return
}
if (!props.item || !props.item.id) {
emit('cancel')
emit('save', { id: 0, content: text, project_id: formProjectId.value, category: formCategory.value, minutes: formMinutes.value && formMinutes.value > 0 ? formMinutes.value : null, takeaway: formTakeaway.value.trim() || null, remark: formRemark.value.trim() || null, is_milestone: formMilestone.value, needs_followup: formFollowup.value })
return
}
emit('save', {
@@ -110,7 +113,7 @@ function save() {
<div class="dialog" role="dialog" aria-label="编辑工作记录">
<div class="dialog-head">
<div>
<div class="dialog-title">编辑工作记录</div>
<div class="dialog-title">{{ item ? '编辑工作记录' : '添加工作记录' }}</div>
<div v-if="date" class="muted dialog-sub">{{ date }}</div>
</div>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
@@ -137,7 +140,7 @@ function save() {
<div class="field">
<label>工作分类</label>
<select class="select" v-model="formCategory">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field">
@@ -170,7 +173,7 @@ function save() {
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存修改</button>
<button class="btn btn-primary btn-sm" @click="save">{{ item ? '保存修改' : '添加' }}</button>
</div>
</div>
</div>
@@ -179,65 +182,5 @@ function save() {
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 560px;
max-width: calc(100vw - 32px);
max-height: 85vh;
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
display: flex;
flex-direction: column;
}
.dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex: none;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-sub { font-size: 12px; margin-top: 2px; }
.dialog-body { padding: 16px 20px; overflow: auto; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
flex: none;
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid, .input.invalid:focus { border-color: var(--danger); }
.toggle-btn {
height: 32px;
width: 100%;
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-2);
}
.toggle-btn:hover { background: var(--bg-hover); }
.toggle-btn.on { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.toggle-btn.onwarn { color: var(--warn); border-color: var(--warn); }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.dialog { width: 560px; }
</style>
+141 -105
View File
@@ -1,27 +1,9 @@
import Dexie, { type Table } from 'dexie'
// 身份认证:账号与密码哈希只保存在服务器(PBKDF2)
// 本机仅保存"会话标识"与自动登录凭据(localStorage),用于免重复登录
// 业务数据一律在服务器,本机数据库只是缓存(由同步引擎全量刷新)
import { bindDatabase, db, unbindDatabase, WorklogDB } from './schema'
import { lockVault } from './vault'
import { pbkdf2Hash, randomSaltB64 } from '../lib/crypto'
interface UserRecord {
id: string
username: string
pwd_hash: string
pwd_salt: string
legacy_imported_at?: string
created_at: string
}
// 用户注册表:独立小库,仅存用户名与密码哈希,不存任何业务数据
class UserRegistry extends Dexie {
user!: Table<UserRecord, string>
constructor() {
super('intern_worklog_users')
this.version(1).stores({ user: 'id, &username' })
}
}
const registry = new UserRegistry()
import { getSyncBase } from '../lib/server'
const LEGACY_DB_NAME = 'intern_worklog'
const SESSION_KEY = 'dsh_session_user'
@@ -31,130 +13,184 @@ export interface Session {
username: string
}
// 会话存 sessionStorage:关闭标签页即退出,刷新页面保持登录
// ---------- 本地会话与凭据 ----------
export function getSession(): Session | null {
try {
const raw = sessionStorage.getItem(SESSION_KEY)
const raw = localStorage.getItem(SESSION_KEY)
if (!raw) return null
const s = JSON.parse(raw)
if (s && s.userId && s.username) return s as Session
} catch { /* 忽略损坏会话 */ }
return null
}
function setSession(s: Session): void {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(s))
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
}
function clearSession(): void {
sessionStorage.removeItem(SESSION_KEY)
localStorage.removeItem(SESSION_KEY)
}
// 云端凭据:同用户名同密码,用于应用启动时免输密码的自动登录
const CREDS_KEY = 'dsh_cloud_creds'
export function setCloudCreds(username: string, password: string): void {
localStorage.setItem(CREDS_KEY, JSON.stringify({ username, password }))
}
export function getCloudCreds(): { username: string; password: string } | null {
try {
const raw = localStorage.getItem(CREDS_KEY)
if (!raw) return null
const c = JSON.parse(raw)
if (c && c.username && c.password) return c as { username: string; password: string }
} catch { /* 忽略损坏数据 */ }
return null
}
export function clearCloudCreds(): void {
localStorage.removeItem(CREDS_KEY)
}
// ---------- 用户数据库(本地缓存实例) ----------
let openedFor: string | null = null
export function openUserDatabase(userId: string): void {
if (openedFor === userId && db) return
if (db) unbindDatabase()
bindDatabase(new WorklogDB(LEGACY_DB_NAME + '_' + userId))
openedFor = userId
}
export async function createUser(username: string, password: string, migrateLegacy: boolean): Promise<Session> {
const name = username.trim()
if (name.length < 2 || name.length > 20) throw new Error('用户名需 2 到 20 个字符')
if (password.length < 6) throw new Error('登录密码至少 6 位')
const exists = await registry.user.where('username').equals(name).first()
if (exists) throw new Error('用户名已存在')
const salt = randomSaltB64()
const hash = await pbkdf2Hash(password, salt)
const id = 'u_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
await registry.user.add({ id, username: name, pwd_hash: hash, pwd_salt: salt, created_at: new Date().toISOString() })
openUserDatabase(id)
let copied = 0
if (migrateLegacy && await legacyDataExists()) {
copied = await migrateLegacyInto()
await registry.user.update(id, { legacy_imported_at: new Date().toISOString() })
// ---------- 登录 / 注册(全部经由服务器) ----------
async function serverAuth(path: 'login' | 'register', username: string, password: string): Promise<{ userId: string; username: string }> {
const base = await getSyncBase()
let resp: Response
try {
resp = await fetch(base + '/api/auth/' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password })
})
} catch {
throw new Error('无法连接同步服务器:请检查网络后重试')
}
const session: Session = { userId: id, username: name }
const j = await resp.json().catch(() => ({}))
if (!resp.ok) {
const msg = (j && j.error) || ('服务返回 ' + resp.status)
if (path === 'register' && resp.status === 409) throw new Error('云端已有同名账号且密码一致,直接登录即可')
throw new Error(String(msg))
}
if (!j.userId || !j.username) throw new Error('登录响应异常')
return { userId: String(j.userId), username: String(j.username) }
}
async function establishSession(username: string, password: string, path: 'login' | 'register'): Promise<Session> {
const info = await serverAuth(path, username, password)
openUserDatabase(info.userId)
const session: Session = { userId: info.userId, username: info.username }
setSession(session)
if (copied) showToastCopied(copied)
setCloudCreds(info.username, password)
return session
}
export async function login(username: string, password: string): Promise<Session> {
const name = username.trim()
const rec = await registry.user.where('username').equals(name).first()
if (!rec) throw new Error('用户不存在')
const hash = await pbkdf2Hash(password, rec.pwd_salt)
if (hash !== rec.pwd_hash) throw new Error('密码不正确')
openUserDatabase(rec.id)
const session: Session = { userId: rec.id, username: rec.username }
setSession(session)
return session
const name = (username || '').trim()
if (!name || !password) throw new Error('请输入用户名与密码')
return establishSession(name, password, 'login')
}
export interface RegisterOptions { migrateLegacy?: boolean }
export async function createUser(username: string, password: string): Promise<Session>
export async function createUser(username: string, password: string, migrateLegacy: boolean): Promise<Session>
export async function createUser(username: string, password: string, _migrateLegacy?: boolean): Promise<Session> {
const name = (username || '').trim()
if (name.length < 2 || name.length > 20) throw new Error('用户名需 2 到 20 个字符')
if (password.length < 6) throw new Error('登录密码至少 6 位')
return establishSession(name, password, 'register')
}
// 启动引导:已保存过凭据则静默登录,直接进入应用
export async function bootstrapSession(): Promise<Session | null> {
const creds = getCloudCreds()
if (!creds) return null
try {
return await establishSession(creds.username, creds.password, 'login')
} catch {
return null
}
}
export async function logoutUser(): Promise<void> {
lockVault()
unbindDatabase()
openedFor = null
// 保留云端凭据:退出登录后再次打开仍可一键重新进入;如需彻底退出请删除用户
clearSession()
}
export function forgetDevice(): void {
lockVault()
unbindDatabase()
openedFor = null
clearSession()
clearCloudCreds()
}
export async function changeLoginPassword(oldPwd: string, newPwd: string): Promise<void> {
const s = getSession()
if (!s) throw new Error('未登录')
if (newPwd.length < 6) throw new Error('新密码至少 6 位')
const rec = await registry.user.get(s.userId)
if (!rec) throw new Error('用户不存在')
const oldHash = await pbkdf2Hash(oldPwd, rec.pwd_salt)
if (oldHash !== rec.pwd_hash) throw new Error('旧密码不正确')
const salt = randomSaltB64()
const hash = await pbkdf2Hash(newPwd, salt)
await registry.user.update(rec.id, { pwd_hash: hash, pwd_salt: salt })
const creds = getCloudCreds()
if (!creds || creds.password !== oldPwd) throw new Error('旧密码不正确')
const base = await getSyncBase()
let resp: Response
try {
resp = await fetch(base + '/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getCachedToken() },
body: JSON.stringify({ oldPassword: oldPwd, newPassword: newPwd })
})
} catch {
throw new Error('无法连接同步服务器')
}
if (!resp.ok) {
const j = await resp.json().catch(() => ({}))
throw new Error((j && j.error) || ('修改失败 ' + resp.status))
}
setCloudCreds(creds.username, newPwd)
}
export async function deleteCurrentUser(): Promise<void> {
const s = getSession()
if (!s) throw new Error('未登录')
lockVault()
unbindDatabase()
await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId)
await registry.user.delete(s.userId)
clearSession()
const base = await getSyncBase()
let resp: Response
try {
resp = await fetch(base + '/api/auth/delete', {
method: 'POST',
headers: { Authorization: 'Bearer ' + getCachedToken() }
})
} catch {
throw new Error('无法连接同步服务器')
}
if (!resp.ok) {
const j = await resp.json().catch(() => ({}))
throw new Error((j && j.error) || ('删除失败 ' + resp.status))
}
forgetDevice()
}
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
// 令牌由同步引擎负责登录换取/续期;此处读取共享的本地令牌副本
import { getToken } from '../lib/server'
function getCachedToken(): string {
return getToken()
}
// 兼容旧导出:纯在线模式下不再存在本机旧数据迁移(账号与数据都在服务器)
export async function legacyDataExists(): Promise<boolean> {
return Dexie.exists(LEGACY_DB_NAME)
}
async function migrateLegacyInto(): Promise<number> {
if (!db) throw new Error('用户数据库未就绪')
const legacy = new WorklogDB(LEGACY_DB_NAME)
const legacyData = await Promise.all([
legacy.project.toArray(),
legacy.work_log.toArray(),
legacy.log_item.toArray(),
legacy.attendance.toArray(),
legacy.weekly_note.toArray(),
legacy.setting.toArray(),
legacy.memo.toArray(),
legacy.credential.toArray()
])
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear()
])
if (legacyData[0].length) await db.project.bulkAdd(legacyData[0] as never[])
if (legacyData[1].length) await db.work_log.bulkAdd(legacyData[1] as never[])
if (legacyData[2].length) await db.log_item.bulkAdd(legacyData[2] as never[])
if (legacyData[3].length) await db.attendance.bulkAdd(legacyData[3] as never[])
if (legacyData[4].length) await db.weekly_note.bulkAdd(legacyData[4] as never[])
if (legacyData[5].length) await db.setting.bulkAdd(legacyData[5] as never[])
if (legacyData[6].length) await db.memo.bulkAdd(legacyData[6] as never[])
if (legacyData[7].length) await db.credential.bulkAdd(legacyData[7] as never[])
})
const count = legacyData[0].length + legacyData[1].length + legacyData[2].length +
legacyData[3].length + legacyData[4].length + legacyData[6].length + legacyData[7].length
legacy.close()
return count
}
function showToastCopied(count: number): void {
// 迁移完成的反馈由登录后的轻提示呈现(此处在会话建立前,暂存标记)
try { sessionStorage.setItem('dsh_migrate_toast', String(count)) } catch { /* 忽略 */ }
void LEGACY_DB_NAME
return false
}
+39
View File
@@ -0,0 +1,39 @@
// 分类管理:工作分类与记账分类均为用户数据(存于服务器随账号同步)
// 未自定义时返回默认值;分类值即显示文本(历史遗留 key 由 categoryLabel 直通转换)
import { getSetting, setSetting } from './repo'
export const DEFAULT_LOG_CATEGORIES: string[] = ['制度学习', '产品学习', '打杂', '项目实操', '售后服务']
export const DEFAULT_EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const DEFAULT_INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
const LOG_KEY = 'log_categories'
const EXPENSE_KEY = 'ledger_expense_categories'
const INCOME_KEY = 'ledger_income_categories'
function parse(raw: string, fallback: string[]): string[] {
if (!raw) return fallback.slice()
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) {
const clean = arr.filter((x: unknown) => typeof x === 'string' && x.trim()).map((x: string) => x.trim())
if (clean.length) return Array.from(new Set(clean))
}
} catch { /* 损坏数据回退默认 */ }
return fallback.slice()
}
export async function getLogCategories(): Promise<string[]> {
return parse(await getSetting(LOG_KEY, ''), DEFAULT_LOG_CATEGORIES)
}
export async function setLogCategories(list: string[]): Promise<void> {
await setSetting(LOG_KEY, JSON.stringify(list))
}
export async function getLedgerCategories(type: 'expense' | 'income'): Promise<string[]> {
return parse(await getSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, ''), type === 'expense' ? DEFAULT_EXPENSE_CATEGORIES : DEFAULT_INCOME_CATEGORIES)
}
export async function setLedgerCategories(type: 'expense' | 'income', list: string[]): Promise<void> {
await setSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, JSON.stringify(list))
}
+321 -25
View File
@@ -1,5 +1,20 @@
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type WeeklyNote, type WorkLog } from './schema'
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Goal, type PlanItem, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
// 视图直接从 repo 导入这些类型:在此统一再导出
export type { Attendance, Credential, LogItem, Memo, Goal, PlanItem, Project, Transaction, WeeklyNote, WorkLog }
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
import { flushAfterWrite } from '../sync/engine'
// 这些本地临时键不参与同步,写入时不触发推送
const LOCAL_ONLY_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
// 写后即时提交:推送 + 快照重建;失败向上抛(调用方已有 toast 展示)
async function commit(): Promise<void> {
await flushAfterWrite()
}
// ---------- 设置 ----------
@@ -10,6 +25,7 @@ export async function getSetting(key: string, fallback: string): Promise<string>
export async function setSetting(key: string, value: string): Promise<void> {
await db.setting.put({ key, value })
if (!LOCAL_ONLY_KEYS.has(key)) await commit()
}
export interface WorkRule {
@@ -52,15 +68,21 @@ export async function setAttendanceLocation(date: string, location: string): Pro
const loc = location.trim()
await setSetting('last_location', loc)
if (loc) await rememberLocation(loc)
let committed = false
const row = await getAttendance(date)
if (row && row.id) {
await db.attendance.update(row.id, { location: loc || null })
if ((row.location || '') !== loc) {
await db.attendance.update(row.id, { location: loc || null })
committed = true
}
} else if (loc) {
await db.attendance.add({
date, clock_in_at: null, clock_out_at: null,
kind: 'normal', note: null, location: loc, created_at: nowLocalISO()
})
committed = true
}
if (committed) await commit()
}
export async function getLocationPresets(): Promise<string[]> {
@@ -99,14 +121,17 @@ export async function listProjects(includeArchived = false): Promise<Project[]>
export async function addProject(name: string): Promise<void> {
await db.project.add({ name: name.trim(), archived: 0, created_at: nowLocalISO() })
await commit()
}
export async function renameProject(id: number, name: string): Promise<void> {
await db.project.update(id, { name: name.trim() })
await commit()
}
export async function setProjectArchived(id: number, archived: boolean): Promise<void> {
await db.project.update(id, { archived: archived ? 1 : 0 })
await commit()
}
// ---------- 打卡 ----------
@@ -117,17 +142,22 @@ export async function getAttendance(date: string): Promise<Attendance | undefine
export async function clockIn(date: string, location?: string): Promise<void> {
const loc = ((location === undefined ? await getSetting('last_location', '') : location) || '').trim()
let committed = false
const row = await getAttendance(date)
if (row && row.id) {
if (!row.clock_in_at) {
const patch: Partial<Attendance> = { clock_in_at: nowLocalISO() }
if (loc) patch.location = loc
await db.attendance.update(row.id, patch)
committed = true
}
if (loc) {
await setSetting('last_location', loc)
if ((await getSetting('last_location', '')) !== loc) {
await setSetting('last_location', loc)
}
await rememberLocation(loc)
}
if (committed) await commit()
return
}
await db.attendance.add({
@@ -138,31 +168,38 @@ export async function clockIn(date: string, location?: string): Promise<void> {
await setSetting('last_location', loc)
await rememberLocation(loc)
}
await commit()
}
export async function clockOut(date: string): Promise<void> {
const row = await getAttendance(date)
if (row && row.id && row.clock_in_at && !row.clock_out_at) {
await db.attendance.update(row.id, { clock_out_at: nowLocalISO() })
await commit()
}
}
export async function smartClockOut(): Promise<string> {
const now = new Date()
const today = toDateStr(now)
let touched = ''
if (now.getHours() < 4) {
const yRec = await getAttendance(addDays(today, -1))
if (yRec && yRec.id && yRec.clock_in_at && !yRec.clock_out_at) {
await db.attendance.update(yRec.id, { clock_out_at: nowLocalISO() })
return yRec.date
touched = yRec.date
}
}
const tRec = await getAttendance(today)
if (tRec && tRec.id && tRec.clock_in_at && !tRec.clock_out_at) {
await db.attendance.update(tRec.id, { clock_out_at: nowLocalISO() })
return tRec.date
if (!touched) {
const tRec = await getAttendance(today)
if (tRec && tRec.id && tRec.clock_in_at && !tRec.clock_out_at) {
await db.attendance.update(tRec.id, { clock_out_at: nowLocalISO() })
touched = tRec.date
}
}
throw new Error('没有进行中的打卡')
if (!touched) throw new Error('没有进行中的打卡')
await commit()
return touched
}
export async function saveMakeup(date: string, inAt: string, outAt: string, note: string, location?: string): Promise<void> {
@@ -179,6 +216,7 @@ export async function saveMakeup(date: string, inAt: string, outAt: string, note
}
if (row && row.id) await db.attendance.update(row.id, patch)
else await db.attendance.add(patch)
await commit()
}
// 直接修改某天的上/下班时间(行内编辑),状态由 computeDay 运行时自动重算
@@ -188,6 +226,7 @@ export async function setAttendanceTime(date: string, field: 'clock_in_at' | 'cl
const row = await getAttendance(date)
if (row && row.id) {
await db.attendance.update(row.id, { [field]: date + 'T' + t + ':00' })
await commit()
}
}
@@ -205,6 +244,7 @@ export async function setLeave(date: string, leave: boolean): Promise<void> {
} else if (row && row.id) {
await db.attendance.update(row.id, { kind: 'normal' })
}
await commit()
}
// ---------- 工作日志 ----------
@@ -237,8 +277,17 @@ export interface NewLogItem {
}
export async function addLogItem(date: string, data: NewLogItem): Promise<void> {
const wl = await ensureWorkLog(date)
await db.log_item.add({
let wlExisted = true
let changedWorkLog = false
const existing = await db.work_log.where('date').equals(date).first()
let wl: WorkLog
if (existing && existing.id) wl = existing
else {
wlExisted = false
wl = await ensureWorkLog(date)
changedWorkLog = true
}
await db.table('log_item').add({
work_log_id: wl.id as number,
content: data.content,
project_id: data.project_id,
@@ -251,23 +300,35 @@ export async function addLogItem(date: string, data: NewLogItem): Promise<void>
sort_order: 0,
created_at: nowLocalISO()
})
await setSetting('last_project', data.project_id ? String(data.project_id) : '')
await setSetting('last_category', data.category)
const lpKey = data.project_id ? String(data.project_id) : ''
if ((await getSetting('last_project', '')) !== lpKey) {
await setSetting('last_project', lpKey)
changedWorkLog = true
}
if ((await getSetting('last_category', '')) !== data.category) {
await setSetting('last_category', data.category)
changedWorkLog = true
}
void wlExisted
await commit()
void changedWorkLog
}
export async function updateLogItem(id: number, patch: Partial<LogItem>): Promise<void> {
await db.log_item.update(id, patch)
await db.table('log_item').update(id, patch)
await commit()
}
export async function deleteLogItem(id: number): Promise<void> {
await db.log_item.delete(id)
await db.table('log_item').delete(id)
await commit()
}
export async function listLogItemsBetween(start: string, end: string): Promise<(LogItem & { date: string })[]> {
const wls = await db.work_log.where('date').between(start, end, true, true).toArray()
if (!wls.length) return []
const map = new Map<number, string>(wls.map(w => [w.id as number, w.date]))
const items = await db.log_item.where('work_log_id').anyOf(wls.map(w => w.id as number)).toArray()
const items = await db.table('log_item').where('work_log_id').anyOf(wls.map(w => w.id as number)).toArray()
return items
.map(it => ({ ...it, date: map.get(it.work_log_id) || '' }))
.sort((a, b) => (a.date === b.date ? (a.id as number) - (b.id as number) : a.date < b.date ? -1 : 1))
@@ -295,12 +356,193 @@ export async function saveWeeklyNote(
created_at: nowLocalISO()
})
}
await commit()
}
export async function listWeeklyNotesBetween(start: string, end: string): Promise<WeeklyNote[]> {
return db.weekly_note.where('week_start').between(start, end, true, true).toArray()
}
// ---------- 计划模式 ----------
export interface PlanInput {
date: string
start_time: string
end_time: string
title: string
kind: string
note: string
}
export async function listPlansByDate(date: string): Promise<PlanItem[]> {
const rows = await db.plan.where('date').equals(date).toArray()
return rows.sort((a, b) => (a.start_time < b.start_time ? -1 : 1))
}
export async function addPlan(input: PlanInput): Promise<void> {
const now = nowLocalISO()
await db.plan.add({
date: input.date,
start_time: input.start_time,
end_time: input.end_time,
title: input.title.trim(),
kind: input.kind.trim() || '其他',
note: input.note || null,
status: 'todo',
actual_minutes: 0,
timer_started_at: null,
uuid: '',
created_at: now,
updated_at: now
} as never)
await commit()
}
export async function updatePlan(id: number, patch: Partial<PlanItem>): Promise<void> {
await db.plan.update(id, { ...patch, updated_at: nowLocalISO() })
await commit()
}
export async function deletePlan(id: number): Promise<void> {
await db.plan.delete(id)
await commit()
}
// 打卡:完成 / 跳过 / 恢复
export async function setPlanStatus(id: number, status: 'todo' | 'done' | 'skip'): Promise<void> {
const row = await db.plan.get(id)
if (!row) return
if (status !== 'todo' && row.timer_started_at) {
await stopPlanTimer(id)
}
await db.plan.update(id, { status, updated_at: nowLocalISO() })
await commit()
}
// 计时时钟:同一时刻只允许一个条目在计时,开始新计时会自动停止上一个
export async function startPlanTimer(id: number): Promise<void> {
const running = await db.plan.filter(p => !!p.timer_started_at).toArray()
for (const p of running) {
if (p.id !== id) await stopPlanTimer(p.id as number)
}
await db.plan.update(id, { timer_started_at: nowLocalISO(), updated_at: nowLocalISO() })
await commit()
}
export async function stopPlanTimer(id: number): Promise<void> {
const row = await db.plan.get(id)
if (!row || !row.timer_started_at) return
const elapsed = Math.max(0, Math.round((Date.now() - new Date(row.timer_started_at).getTime()) / 60000))
await db.plan.update(id, {
actual_minutes: (row.actual_minutes || 0) + elapsed,
timer_started_at: null,
updated_at: nowLocalISO()
})
await commit()
}
// ---------- 长期计划(目标) ----------
export interface GoalInput {
title: string
kind: string
start_date: string
end_date: string
note: string
}
const MAX_MATERIALIZE_DAYS = 366
function eachDate(start: string, end: string): string[] {
const out: string[] = []
let cur = start
let guard = 0
while (cur <= end && guard < MAX_MATERIALIZE_DAYS) {
out.push(cur)
cur = addDays(cur, 1)
guard++
}
return out
}
export async function getGoalProgress(): Promise<{ goalId: number; done: number; total: number }[]> {
const goals = await db.goal.toArray()
const out: { goalId: number; done: number; total: number }[] = []
for (const g of goals) {
const items = await db.plan.where('goal_id').equals(g.id as number).toArray()
out.push({ goalId: g.id as number, done: items.filter(i => i.status === 'done').length, total: items.length })
}
return out
}
export async function listGoals(): Promise<Goal[]> {
const rows = await db.goal.toArray()
return rows.sort((a, b) => (a.start_date < b.start_date ? -1 : 1))
}
// 新建长期计划并物化每一天的任务(落实每日打卡与计时)
export async function addGoal(input: GoalInput): Promise<void> {
const now = nowLocalISO()
const gid = await db.goal.add({
title: input.title.trim(),
kind: input.kind.trim() || '其他',
start_date: input.start_date,
end_date: input.end_date,
note: input.note || null,
uuid: '',
created_at: now,
updated_at: now
} as never)
const goal = await db.goal.get(gid)
const dates = eachDate(input.start_date, input.end_date)
for (const d of dates) {
await db.plan.add({
date: d,
start_time: '08:00',
end_time: '09:00',
title: input.title.trim(),
kind: input.kind.trim() || '其他',
note: input.note || null,
status: 'todo',
actual_minutes: 0,
timer_started_at: null,
goal_id: gid,
uuid: '',
created_at: now,
updated_at: now
} as never)
}
void goal
await commit()
}
// 编辑目标:标题/类别级联到其每日任务(已完成项不改动)
export async function updateGoal(id: number, patch: Partial<GoalInput>): Promise<void> {
const goal = await db.goal.get(id)
if (!goal) return
const next = { ...patch, updated_at: nowLocalISO() }
await db.goal.update(id, next)
const items = await db.plan.where('goal_id').equals(id).toArray()
for (const it of items) {
if (it.status === 'done') continue
await db.plan.update(it.id as number, {
...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
...(patch.kind !== undefined ? { kind: patch.kind } : {}),
...(patch.note !== undefined ? { note: patch.note || null } : {}),
updated_at: nowLocalISO()
})
}
await commit()
}
// 删除目标:连同其每日任务一起删除(前端二次确认)
export async function deleteGoal(id: number): Promise<void> {
const items = await db.plan.where('goal_id').equals(id).toArray()
for (const it of items) await db.plan.delete(it.id as number)
await db.goal.delete(id)
await commit()
}
// ---------- 备忘录 ----------
export async function listMemos(): Promise<Memo[]> {
@@ -311,15 +553,18 @@ export async function listMemos(): Promise<Memo[]> {
export async function addMemo(title: string, content: string): Promise<Memo> {
const now = nowLocalISO()
const id = await db.memo.add({ title: title.trim(), content, created_at: now, updated_at: now })
await commit()
return { id, title: title.trim(), content, created_at: now, updated_at: now }
}
export async function updateMemo(id: number, title: string, content: string): Promise<void> {
await db.memo.update(id, { title: title.trim(), content, updated_at: nowLocalISO() })
await commit()
}
export async function deleteMemo(id: number): Promise<void> {
await db.memo.delete(id)
await commit()
}
// ---------- 全局搜索 ----------
@@ -329,7 +574,7 @@ export async function searchLogItems(q: string, limit = 8): Promise<(LogItem & {
if (!needle) return []
const wls = await db.work_log.toArray()
const map = new Map<number, string>(wls.map(w => [w.id as number, w.date]))
const all = await db.log_item.toArray()
const all = await db.table('log_item').toArray()
return all
.filter(it => ((it.content || '') + ' ' + (it.takeaway || '') + ' ' + (it.remark || '')).toLowerCase().includes(needle))
.sort((a, b) => (b.id as number) - (a.id as number))
@@ -347,18 +592,57 @@ export async function searchMemos(q: string, limit = 4): Promise<Memo[]> {
.slice(0, limit)
}
// ---------- 记账 ----------
export interface TransactionInput {
type: string
category: string
amount: number
date: string
note: string
reimbursable: number
reimbursed: number
}
export async function listTransactionsBetween(start: string, end: string): Promise<Transaction[]> {
return db.table('transaction').where('date').between(start, end, true, true).toArray()
}
export async function addTransaction(input: TransactionInput): Promise<void> {
await db.table('transaction').add({ ...input, created_at: nowLocalISO() })
await commit()
}
export async function updateTransaction(id: number, input: TransactionInput): Promise<void> {
await db.table('transaction').update(id, { ...input })
await commit()
}
export async function deleteTransaction(id: number): Promise<void> {
await db.table('transaction').delete(id)
await commit()
}
export async function setTransactionReimbursed(id: number, done: boolean): Promise<void> {
await db.table('transaction').update(id, { reimbursed: done ? 1 : 0 })
await commit()
}
// ---------- 备份 ----------
export async function exportAll(): Promise<string> {
const tables = await Promise.all([
db.project.toArray(),
db.work_log.toArray(),
db.log_item.toArray(),
db.table('log_item').toArray(),
db.attendance.toArray(),
db.weekly_note.toArray(),
db.setting.toArray(),
db.memo.toArray(),
db.credential.toArray()
db.credential.toArray(),
db.table('transaction').toArray(),
db.plan.toArray(),
db.goal.toArray()
])
return JSON.stringify({
schema_version: SCHEMA_VERSION,
@@ -371,7 +655,10 @@ export async function exportAll(): Promise<string> {
weekly_note: tables[4],
setting: tables[5],
memo: tables[6],
credential: tables[7]
credential: tables[7],
transaction: tables[8],
plan: tables[9],
goal: tables[10]
}
}, null, 2)
}
@@ -383,23 +670,32 @@ export async function importAll(jsonText: string): Promise<void> {
} catch {
throw new Error('不是有效的 JSON 文件')
}
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2) || !parsed.data) {
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3 && parsed.schema_version !== 4 && parsed.schema_version !== 5) || !parsed.data) {
throw new Error('文件格式或 schema 版本不匹配')
}
const d = parsed.data
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential], async () => {
const dl = db.table('log_item')
const dt = db.table('transaction')
const dp = db.plan
const dg = db.goal
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt, dg, dp], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
db.project.clear(), db.work_log.clear(), dl.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear()
db.memo.clear(), db.credential.clear(), dt.clear(), dg.clear(), dp.clear()
])
if (d.project && d.project.length) await db.project.bulkAdd(d.project as never[])
if (d.work_log && d.work_log.length) await db.work_log.bulkAdd(d.work_log as never[])
if (d.log_item && d.log_item.length) await db.log_item.bulkAdd(d.log_item as never[])
if (d.log_item && d.log_item.length) await dl.bulkAdd(d.log_item as never[])
if (d.attendance && d.attendance.length) await db.attendance.bulkAdd(d.attendance as never[])
if (d.weekly_note && d.weekly_note.length) await db.weekly_note.bulkAdd(d.weekly_note as never[])
if (d.setting && d.setting.length) await db.setting.bulkAdd(d.setting as never[])
if (d.memo && d.memo.length) await db.memo.bulkAdd(d.memo as never[])
if (d.credential && d.credential.length) await db.credential.bulkAdd(d.credential as never[])
if (d.transaction && d.transaction.length) await dt.bulkAdd(d.transaction as never[])
if (d.goal && d.goal.length) await dg.bulkAdd(d.goal as never[])
if (d.plan && d.plan.length) await dp.bulkAdd(d.plan as never[])
})
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
await commit()
}
+107 -12
View File
@@ -48,6 +48,35 @@ export interface WeeklyNote {
created_at: string
}
export interface Goal {
id?: number
title: string // 长期计划,如:学完产品文档 / 本月健身 20 次
kind: string
start_date: string // YYYY-MM-DD
end_date: string
note: string | null
uuid: string
created_at: string
updated_at: string
}
export interface PlanItem {
id?: number
date: string // YYYY-MM-DD
start_time: string // HH:mm
end_time: string // HH:mm
title: string // 计划内容,如:产品学习 / 健身
kind: string // 类别:学习 / 健身 / 自律 / 其他自定义
note: string | null
status: string // todo | done | skip
actual_minutes: number // 计时时钟累计的实际投入分钟
timer_started_at: string | null
goal_id: number | null // 来源长期计划(本地 id)
uuid: string
created_at: string
updated_at: string
}
export interface Memo {
id?: number
title: string
@@ -66,6 +95,34 @@ export interface Credential {
updated_at: string
}
export interface Transaction {
id?: number
type: string // income | expense
category: string
amount: number // 单位:分
date: string // YYYY-MM-DD
note: string
reimbursable: number // 支出是否需要报销
reimbursed: number // 是否已报销
created_at: string
}
// 同步镜像:记录上次同步时每条数据的哈希,用于增量差异检测
export interface SyncMirror {
id?: number
table: string
uuid: string
hash: string
}
// 删除墓碑:同步时告知其他端该记录已删除
export interface SyncTombstone {
id?: number
table: string
uuid: string
deleted_at: string
}
export class WorklogDB extends Dexie {
project!: Table<Project, number>
work_log!: Table<WorkLog, number>
@@ -75,6 +132,12 @@ export class WorklogDB extends Dexie {
setting!: Table<{ key: string; value: string }, string>
memo!: Table<Memo, number>
credential!: Table<Credential, number>
// 注意:transaction 表通过 db.table('transaction') 访问,
// 因为 db.transaction 会与 Dexie 内置的 transaction() 方法冲突
sync_mirror!: Table<SyncMirror, number>
tombstone!: Table<SyncTombstone, number>
plan!: Table<PlanItem, number>
goal!: Table<Goal, number>
constructor(dbName = 'intern_worklog') {
super(dbName)
@@ -90,6 +153,34 @@ export class WorklogDB extends Dexie {
memo: '++id, updated_at',
credential: '++id, system, updated_at'
})
this.version(3).stores({
transaction: '++id, date, type, category'
})
this.version(4).stores({
sync_mirror: '++id, table, uuid',
tombstone: '++id, table, uuid, deleted_at'
})
this.version(5).stores({
plan: '++id, date, status'
}).upgrade(async tx => {
// 旧固定分类 key 迁移为用户自定义分类标签(v0.4.0 起分类为用户数据)
const map: Record<string, string> = {
dev: '项目实操',
test: '项目实操',
bugfix: '售后服务',
learning: '产品学习',
meeting: '打杂',
doc: '打杂',
other: '打杂'
}
await tx.table('log_item').toCollection().modify((item: { category?: string }) => {
if (item.category && map[item.category]) item.category = map[item.category]
})
})
this.version(6).stores({
plan: '++id, date, status, goal_id',
goal: '++id, start_date'
})
}
}
@@ -105,21 +196,25 @@ export function unbindDatabase(): void {
db = undefined
}
export const SCHEMA_VERSION = 2
export const SCHEMA_VERSION = 6
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
export const DEFAULT_LOCATIONS: string[] = ['公司', '客户现场', '出差', '居家办公']
export const CATEGORIES: { key: string; label: string }[] = [
{ key: 'dev', label: '开发' },
{ key: 'bugfix', label: 'BUG修复' },
{ key: 'test', label: '测试' },
{ key: 'learning', label: '学习成长' },
{ key: 'meeting', label: '会议沟通' },
{ key: 'doc', label: '文档输出' },
{ key: 'other', label: '其他' }
]
// 旧版固定分类 key → 默认标签的映射(仅用于历史数据迁移与展示兜底)
export const LEGACY_CATEGORY_LABELS: Record<string, string> = {
dev: '项目实操',
test: '项目实操',
bugfix: '售后服务',
learning: '产品学习',
meeting: '打杂',
doc: '打杂',
other: '打杂'
}
// v0.4.0 起分类即用户自定义标签本身;历史 key 在此直通转换
export function categoryLabel(key: string): string {
const found = CATEGORIES.find(c => c.key === key)
return found ? found.label : '其他'
return LEGACY_CATEGORY_LABELS[key] || key
}

Some files were not shown because too many files have changed in this diff Show More