50 Commits
Author SHA1 Message Date
root 7d11a48756 ui: 移动端触控基线与板块重排(按钮32-36/输入38/弹窗按钮40;出勤2x2;计划操作等宽;记账工具栏分区;导出铺满;底栏56) 2026-10-26 21:07:50 +08:00
root 1afc236d88 storage: 服务端存储加固(原子落盘fsync/损坏隔离/墓碑14天GC/空同步不落盘/当日首份快照不可变/每周深快照/大批删除前置快照/health增强) 2026-10-26 20:51:12 +08:00
root d48330e5c4 fix: 二道防线(服务器行数少于镜像即判定数据丢失,自动全量重推自救,首次升级即可生效) + 灾难恢复冒烟7项 2026-10-26 19:58:59 +08:00
root dc5ab15ae1 fix: 服务器数据纪元保护(空快照不清空本地,自动全量重推自救) / 详情页导出指定日 / 全局计时时钟面板 2026-10-26 17:32:34 +08:00
root 7a36fd4e04 ui: 全站界面重设计(文字侧栏+灰底白卡层次+组件统一) fix: 添加记录弹窗不显示/计时汇总崩溃/CSV导出崩溃/记账切换类型崩溃 2026-10-26 12:40:54 +08:00
root 0f268a495e feat: 长期计划(目标→每日任务物化+进度条) / 详情页Markdown范围导出 / 记账与详情页改内容优先弹窗新增 2026-10-26 12:16:14 +08:00
root 284f8ebd46 feat: 计划模式(时间段+打卡+计时时钟) / 工作详情范围导出 / 工作与记账分类自定义(含历史迁移) 2026-10-26 11:48:14 +08:00
root a1f2a45623 ui: 侧边栏按“记录/工具/分析/系统”重排并分组分隔;精简设置/登录/记账页冗余文案(修正旧架构遗留说明);键盘焦点态 2026-10-17 11:07:04 +08:00
root 08b0317d35 security: 改密即吊销全部旧令牌 + 登录防爆破 + COS 异地备份(环境变量开关) + 启动失败重试按钮 2026-10-17 10:59:25 +08:00
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
138 changed files with 7734 additions and 793 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 及之前版本可直接升级。
+18
View File
@@ -0,0 +1,18 @@
# v0.6.1 发布说明(2026-10-26)
## 数据安全:服务器数据纪元保护(重要)
- 服务端为每个用户的数据文件记录「数据纪元」(首次创建时间),随同步接口返回。
- 客户端检测到纪元变化(说明服务器数据存储被重建,例如误删 data 目录后重启)时,自动作废本地同步镜像,**把本机全量数据重新推回服务器**,避免「服务器空了 → 拉到空快照 → 本地缓存也被清空」的连锁数据丢失。
- 也就是说:只要还有任何一台设备保留着本地缓存,升级到本版本后第一次同步就会把数据恢复到服务器。
- 服务器部署文档已移除危险的 `rm -rf data` 步骤,并明确标注 data 目录为正式数据、严禁删除。
## 功能修正
- 详情页导出:「当日」改为「指定日」——导出的是详情页正在浏览的那一天;切换浏览日期时,导出区间(周/月/自定义)锚点自动跟随。
- 新增**全局计时时钟面板**:计划开始计时后,任何页面右下角都会悬浮显示计时任务(脉冲动画 + 实时走秒 + 一键停止),点击可跳回计划页;手机端悬浮于底部标签栏上方。
## 升级
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(**切勿删除 server/data 目录**)
- 桌面 / 安卓:安装对应 0.6.1 安装包。
+18
View File
@@ -0,0 +1,18 @@
# v0.6.2 发布说明(2026-10-26)
## 数据安全:补上「首次升级即遇灾」的恢复漏洞(关键)
- v0.6.1 的数据纪元保护只对「已存过纪元」的设备生效;所有设备第一次跑新版时检测不到服务器数据被重建。
- 本版本增加**二道防线**(不依赖任何历史状态):若本地镜像认为服务器应有 N 行,而服务器实际行数更少——即服务器数据发生过丢失/重建——立即作废本地镜像,把本机全量数据重推回服务器,再拉取快照,本地与服务器同时恢复。
- 已通过灾难恢复冒烟测试(7/7):删除服务器用户数据文件后,保留缓存的设备下一次同步自动重推全部数据(备忘录、密码库密文、主密码盐与校验值、考勤规则),全新设备随后完整拉回。
## 与数据丢失相关的说明
- 密码库:密文行与主密码盐/校验值均随同步存储于服务器;只要任一快照存活即可整体恢复,主密码不变。
- 备忘录:明文行随同步存储,恢复机制相同。
- 若所有设备都已在数据丢失后打开过应用(本地缓存同样被旧版清空),且服务器 data 目录被删、COS 未配置,则丢失部分无法找回;建议尽快配置 COS 异地备份。
## 升级
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(切勿删除 server/data)
- 桌面 / 安卓:安装 0.6.2;网页端刷新页面即为新版。
+26
View File
@@ -0,0 +1,26 @@
# v0.6.3 发布说明(2026-10-26)
服务端存储层加固(纯服务端变更,客户端无需重装;桌面/安卓继续使用 0.6.2 安装包)。
## 存储可靠性
- **原子落盘**:所有 JSON 写入改为「临时文件 + fsync + 原子改名」,进程崩溃/断电不会留下半截数据文件
- **损坏隔离**:用户数据文件若解析失败,自动隔离为 `.corrupt-<时间戳>` 保留原件并在日志报错——绝不静默当作空数据(旧逻辑的静默空文件正是数据事故的放大器)
- **墓碑自动回收**:删除标记保留 14 天后自动清除,数据文件不再随历史无限膨胀,也不会永久重放旧删除
- **空同步不落盘**:内容无变化的同步不再重写磁盘(顺带修复 cursor 假值导致的每轮必写)
## 备份体系
- **当日首份快照不可变**:同日再备份写入带时间戳的副本目录,当日证据链不再被覆盖(本次事故中 11:11 快照被 20:03 覆盖正是教训)
- **每周深快照**:每周一额外保留一份,保留 8 份
- **大批删除前置保险**:单次同步携带 ≥20 条墓碑时,服务端先自动快照再应用删除,可随时回滚
- `/api/health` 新增 users / lastBackup 字段
## 验证
- 服务端协议审计 36/36、用户管理审计 16/16、灾难恢复自救 7/7、存储加固冒烟 9/9 全部通过
- 修复审计中两处过时预期(墓碑 GC 后老日期墓碑不再回传属预期;当日首份快照布局变更)
## 部署
`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
+23
View File
@@ -0,0 +1,23 @@
# v0.6.4 发布说明(2026-10-26)
## 移动端布局与板块尺寸优化
**触控基线**(全部界面统一):
- 常规按钮 36px / 次级 32px / 小按钮 30px(原 24-28px,触控不友好)
- 输入框与下拉 38px 高;分段选择器 34px;图标按钮 34px
- 弹窗底部主按钮 40px 并等宽铺满;弹窗宽距收窄近全屏
**板块重排**:
- 详情页:出勤信息改 2×2 紧凑网格(原手机上拉成 1 列过高);打卡/休假按钮并排等宽
- 计划页:每日任务操作按钮(计时/完成/跳过)等宽铺满、触控高度 32px;「今天 / 添加计划」等宽;长期计划卡操作角标化
- 记账页:工具栏分区——月份导航一行、两个筛选并排、「记一笔」全宽主按钮
- 详情页导出:指定日/当周/当月/自定义分段等宽铺满,日期输入全宽
- 总结页:范围分段铺满一行;草稿编辑器高度收窄
- 备忘录:列表收窄至 210px,底部按钮等宽
- 底部标签栏加高至 56px、图标 22px;悬浮计时时钟位置同步适配
- 顶栏打卡按钮加高至 34px,更易点按
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.4 安装包
@@ -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-CAumy6rT.js","./index-DHLkCnnx.js","./index-BuZbSc5_.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DHLkCnnx.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-CAumy6rT.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-DHLkCnnx.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BuZbSc5_.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.4",
"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) })
+149
View File
@@ -0,0 +1,149 @@
// 服务端协议审计:认证 / 九张表收发持久化 / 多用户隔离 / 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)
// deleted_at 用当前时间:过期墓碑会被服务端 14 天 GC 自动回收(这是预期行为,不能用老日期验证回传)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: new Date().toISOString() }] }, tokA)
check('墓碑同步后行被清除', !((rowsOf(delPush).memo || []).some(x => x.uuid === 'mt1')))
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1'))
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))
// 当日首份快照不可变:registry.json 可能位于当日基准目录或其 _t 时间戳副本
const today = new Date().toISOString().slice(0, 10)
const todayDirs = fsMod.readdirSync('server/data/backups').filter(d => d.startsWith(today))
check('启动自动备份已生成', todayDirs.length > 0 && todayDirs.some(d => fsMod.existsSync('server/data/backups/' + d + '/registry.json')), todayDirs.join(','))
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
const d1 = await post('/api/auth/delete', {}, tokA)
check('删除账号 200', d1.status === 200, d1.body)
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) })
+82
View File
@@ -0,0 +1,82 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
// node 环境的 localStorage 垫片(引擎的纪元/idMap 存取需要)
const lsMap = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (lsMap.has(k) ? lsMap.get(k)! : null),
setItem: (k: string, v: string) => { lsMap.set(k, String(v)) },
removeItem: (k: string) => { lsMap.delete(k) }
}
const BASE = 'http://127.0.0.1:3780'
let pass = 0
let fail = 0
function check(name: string, ok: boolean, detail?: string): void {
if (ok) { pass++; console.log(' PASS ' + name) }
else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function main() {
await fetch(BASE + '/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'restore', password: 'dev123456' })
}).catch(() => { /* 已存在忽略 */ })
const engine = await import('../src/sync/engine')
const CONF = { baseUrl: BASE, username: 'restore', password: 'dev123456' }
// ===== 设备 A:备忘录 x3 + 密码库密文 x1 + 设置 =====
bindDatabase(new WorklogDB('restore_a'))
const a = (await import('../src/db/schema')).db
const now = '2026-10-26T10:00:00'
await a.memo.bulkAdd([
{ title: '备忘一', content: '内容一', created_at: now, updated_at: now },
{ title: '备忘二', content: '内容二', created_at: now, updated_at: now },
{ title: '备忘三', content: '内容三', created_at: now, updated_at: now }
])
await a.credential.bulkAdd([
{ system: '企业邮箱', account: 'dev@x.com', password: 'enc:v1:FAKECIPHER', remark: '', created_at: now, updated_at: now }
])
await a.setting.bulkAdd([
{ key: 'work_start', value: '09:00' },
{ key: 'pwd_salt', value: 'SALTSALT' },
{ key: 'pwd_verifier', value: 'enc:v1:VERIFIER' }
])
const rA = await engine.runSync(CONF)
console.log('设备A 首次推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// ===== 灾难:服务器用户数据文件被删(模拟 rm -rf data / 目录重建)=====
const fsx = await import('fs')
const usersDir = 'server/data/users'
for (const f of fsx.readdirSync(usersDir)) fsx.rmSync(usersDir + '/' + f)
console.log('已删除服务器用户数据文件')
// ===== 设备 A 再次同步:应触发二道防线,自动全量重推自救 =====
// (设备 A 此前从未存过纪元 —— 恰好覆盖「首次升级即遇到灾难」的窗口)
const rA2 = await engine.runSync(CONF)
console.log('设备A 自救同步:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
check('自救时自动重推了全部本地数据(pushed>=7)', rA2.pushed >= 7, 'pushed=' + rA2.pushed)
const aMemo = await a.memo.toArray()
const aCred = await a.credential.toArray()
check('设备A 本地数据未被清空(备忘录仍 3 条)', aMemo.length === 3, 'len=' + aMemo.length)
check('设备A 本地密码库未被清空(仍 1 条)', aCred.length === 1, 'len=' + aCred.length)
// ===== 全新设备 C:拉取验证服务器已被 A 恢复 =====
bindDatabase(new WorklogDB('restore_c'))
const c = (await import('../src/db/schema')).db
const rC = await engine.runSync(CONF)
console.log('设备C 拉取:', JSON.stringify({ p: rC.pushed, e: rC.errors }))
const cMemo = await c.memo.toArray()
const cCred = await c.credential.toArray()
const cSet = await c.setting.toArray()
check('设备C 拉到 3 条备忘录', cMemo.length === 3, 'len=' + cMemo.length)
check('设备C 拉到密码库密文', cCred.length === 1 && cCred[0].system === '企业邮箱', 'len=' + cCred.length)
check('设备C 拉到主密码盐与校验值(密码库可解锁前提)', cSet.some(s => s.key === 'pwd_salt' && s.value === 'SALTSALT') && cSet.some(s => s.key === 'pwd_verifier'))
check('设备C 拉到考勤规则', cSet.some(s => s.key === 'work_start' && s.value === '09:00'))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail > 0) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+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))
})
+431 -91
View File
@@ -3,41 +3,40 @@ import { computed, onMounted, ref, watch } from 'vue'
import Icon from './components/Icon.vue'
import CalendarView from './views/CalendarView.vue'
import TodayView from './views/TodayView.vue'
import PlanView from './views/PlanView.vue'
import StatsView from './views/StatsView.vue'
import ReportView from './views/ReportView.vue'
import LedgerView from './views/LedgerView.vue'
import MemoView from './views/MemoView.vue'
import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
import TimerPanel from './components/TimerPanel.vue'
import UserAdminView from './views/UserAdminView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
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 +44,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 +112,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 +223,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,29 +288,100 @@ 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>
<!-- 全局计时时钟面板:有计划在计时时悬浮显示 -->
<TimerPanel v-if="session" />
<div class="toast-wrap">
<transition-group name="toast">
<div v-for="t in toasts" :key="t.id" class="toast">{{ t.text }}</div>
@@ -260,40 +389,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 +468,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 +535,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 +552,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 +560,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 +594,172 @@ 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 12px; height: 34px; }
.topbar-actions .btn .num { font-size: 12px; }
.tabbar { display: flex; }
.tab-btn { height: 56px; font-size: 11px; }
.tab-btn .icon { width: 22px; height: 22px; }
.content { flex: 1; min-height: 0; }
.content-inner {
max-width: none;
padding: 12px 12px calc(78px + 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>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { listRunningPlan, stopPlanTimer, type PlanItem } from '../db/repo'
import { planTimerPulse } from '../lib/bus'
import { showToast } from '../lib/toast'
// 全局计时时钟面板:任何页面都能看到正在计时的计划,实时走秒、一键停止、点击跳转计划页
const plan = ref<PlanItem | null>(null)
const nowMs = ref(Date.now())
let poll: number | null = null
let tick: number | null = null
async function refresh() {
try {
plan.value = await listRunningPlan()
} catch { /* 库未就绪时静默,下轮轮询重试 */ }
}
const elapsedSec = computed(() => {
const p = plan.value
if (!p || !p.timer_started_at) return 0
const base = (p.actual_minutes || 0) * 60
const extra = Math.max(0, Math.floor((nowMs.value - new Date(p.timer_started_at).getTime()) / 1000))
return base + extra
})
const elapsedText = computed(() => {
const s = elapsedSec.value
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const ss = s % 60
const p2 = (n: number) => String(n).padStart(2, '0')
return h > 0 ? h + ':' + p2(m) + ':' + p2(ss) : m + ':' + p2(ss)
})
async function stop() {
const p = plan.value
if (!p) return
await stopPlanTimer(p.id as number)
showToast('已停止计时:' + p.title)
await refresh()
}
function goPlan() {
location.hash = '#/plan'
}
watch(planTimerPulse, () => { void refresh() })
onMounted(() => {
void refresh()
poll = window.setInterval(() => { void refresh() }, 15000)
tick = window.setInterval(() => { nowMs.value = Date.now() }, 1000)
window.addEventListener('focus', refresh)
})
onUnmounted(() => {
if (poll !== null) window.clearInterval(poll)
if (tick !== null) window.clearInterval(tick)
window.removeEventListener('focus', refresh)
})
</script>
<template>
<transition name="tp">
<div v-if="plan && plan.timer_started_at" class="tp" @click="goPlan" title="点击查看计划页">
<span class="tp-pulse"></span>
<span class="tp-main">
<span class="tp-title">{{ plan.title }}</span>
<span class="tp-meta">{{ plan.kind }} · 计时中</span>
</span>
<span class="tp-time num">{{ elapsedText }}</span>
<span class="tp-stop" role="button" title="停止计时" @click.stop="stop">停止</span>
</div>
</transition>
</template>
<style scoped>
.tp {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 40;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
max-width: 340px;
padding: 10px 12px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-float);
cursor: pointer;
text-align: left;
transition: border-color 0.12s;
}
.tp:hover { border-color: var(--border-strong); }
.tp-pulse {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
position: relative;
flex: none;
}
.tp-pulse::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--accent);
animation: tp-ring 1.6s ease-out infinite;
}
@keyframes tp-ring {
0% { transform: scale(0.5); opacity: 0.6; }
100% { transform: scale(1.5); opacity: 0; }
}
.tp-main { display: flex; flex-direction: column; min-width: 0; }
.tp-title {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tp-meta { font-size: 11px; color: var(--text-3); }
.tp-time {
font-size: 16px;
font-weight: 600;
color: var(--accent);
font-variant-numeric: tabular-nums;
margin-left: 2px;
flex: none;
}
.tp-stop {
flex: none;
font-size: 12px;
color: var(--text-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 3px 10px;
transition: all 0.12s;
}
.tp-stop:hover { color: var(--danger); border-color: var(--danger); background: #FDF0F0; }
.tp-enter-active, .tp-leave-active { transition: all 0.2s ease; }
.tp-enter-from, .tp-leave-to { opacity: 0; transform: translateY(10px); }
@media (max-width: 720px) {
.tp {
left: 12px;
right: 12px;
bottom: calc(64px + env(safe-area-inset-bottom));
max-width: none;
}
}
</style>
@@ -0,0 +1,170 @@
<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; }
@media (max-width: 720px) {
.add-cat-input { height: 34px; }
.add-cat .btn { height: 34px; }
}
</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"/>')
}

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