Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d11a48756 | ||
|
|
1afc236d88 | ||
|
|
d48330e5c4 | ||
|
|
dc5ab15ae1 | ||
|
|
7a36fd4e04 | ||
|
|
0f268a495e | ||
|
|
284f8ebd46 | ||
|
|
a1f2a45623 | ||
|
|
08b0317d35 | ||
|
|
56a60d5dc1 | ||
|
|
1f792127dc | ||
|
|
344e70bf50 | ||
|
|
aa773062fe | ||
|
|
0d334e8939 | ||
|
|
b33e6ff9b3 | ||
|
|
f6f608a98b | ||
|
|
9bb626cd41 | ||
|
|
9a91c4c85d | ||
|
|
3505e10961 | ||
|
|
cfc8e3c734 | ||
|
|
e5f99d46d8 | ||
|
|
7517eb0ad9 | ||
|
|
8fad42edc7 | ||
|
|
9bd04441e1 | ||
|
|
04ae06ec1c | ||
|
|
aaf8ea602c | ||
|
|
dba22765a6 | ||
|
|
3f7b072ac6 | ||
|
|
8a0fde507f | ||
|
|
8c9f2f0c8b | ||
|
|
e721c356af | ||
|
|
4d2bea139a | ||
|
|
f387ac0194 | ||
|
|
5a67b2f59a | ||
|
|
f0d30e2c29 | ||
|
|
599aa09ab8 | ||
|
|
b87e895766 | ||
|
|
3d05dcb61b | ||
|
|
7aeae0a330 | ||
|
|
b73d5ef2cc | ||
|
|
acfb0cfdd2 | ||
|
|
8108f60825 | ||
|
|
ba07899dbe | ||
|
|
052158d96d | ||
|
|
dccd7c9d76 | ||
|
|
5b48fadc46 | ||
|
|
2660dc6fa9 | ||
|
|
de3add8881 | ||
|
|
ee795eeefb | ||
|
|
838753a058 |
@@ -0,0 +1,2 @@
|
||||
server/data/
|
||||
tmp/
|
||||
@@ -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
|
||||
@@ -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),两端使用相同的登录用户名与密码即可自动互通
|
||||
@@ -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`
|
||||
- 客户端:覆盖安装后用原云端用户名密码登录,数据自动拉回
|
||||
@@ -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 并完成分类迁移
|
||||
@@ -0,0 +1,25 @@
|
||||
# v0.5.0 发布记录
|
||||
|
||||
发布日期:2026-10-16
|
||||
|
||||
## 计划模式增强:长期计划
|
||||
|
||||
- 长期计划(目标)跨越多天:设置起止日期后自动**物化为每一天的任务**,落实到每天打卡与计时
|
||||
- 目标进度条:已完成天数 / 总天数一目了然;编辑目标自动级联更新未完成的每日任务
|
||||
- 每日任务带「长期」徽标,可与当日临时计划混排;删除目标时二次确认
|
||||
- 数据模型新增 goal 表(v6),goal ↔ plan 外键随多端同步(引擎级冒烟验证外键水合正确)
|
||||
|
||||
## 界面原则:内容优先
|
||||
|
||||
- 记账页:移除内联新增表单,改为「记一笔」按钮弹窗填写,月度汇总与账目表前置
|
||||
- 当日详情:移除内联快速记录表单,改为「添加记录」按钮弹窗填写,记录列表前置
|
||||
- 工作详情导出从设置页移至详情页,并支持 **Markdown 格式**(当日 / 当周 / 当月 / 自定义时间段,含考勤与逐条记录)
|
||||
|
||||
## 上一版(v0.4.0)要点回顾
|
||||
|
||||
计划模式(时间段+打卡+计时时钟)、工作与记账分类自定义、工作详情范围导出 CSV
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build
|
||||
- 客户端覆盖安装;本地库自动升级至 v6
|
||||
@@ -0,0 +1,29 @@
|
||||
# v0.6.0 发布说明(2026-10-26)
|
||||
|
||||
## 界面整体重设计
|
||||
|
||||
本版本对全部界面做了系统性改版,统一为「高级白」企业级视觉,信息层次更清晰:
|
||||
|
||||
- **桌面侧栏重做**:由纯图标改为「图标 + 文字」导航,按 记录 / 工具 / 分析 / 系统 分组;新增品牌区与当前用户区(头像、用户名、退出)。
|
||||
- **顶栏精简**:只保留页面标题、全局搜索与打卡按钮;用户信息与退出移入侧栏底部。
|
||||
- **内容层次**:内容区改为浅灰底 + 白色卡片,卡片带细边框与轻投影,主次分明。
|
||||
- **组件统一**:按钮、输入框、表格、弹窗、分段选择器、进度条等全部收敛为全局统一样式,各页面不再各自为政。
|
||||
- **逐页重排**:日历(网格线 + 选中描边)、详情(导航与导出合并为一条工具栏)、计划(长期计划与每日安排分区块、列表合并为单卡)、记账、统计(热力图改品牌蓝)、总结、备忘录、密码库、设置、用户管理。
|
||||
- **登录页升级**:品牌标识、卡片投影。
|
||||
- **平板适配**:721–1000px 侧栏自动折叠为图标栏。
|
||||
- **移动端**:底部标签栏与「更多」弹层沿用,并随新基座精修间距与弹窗。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复:详情页「添加记录」按钮点击无反应(弹窗开关与编辑目标状态混用所致)。
|
||||
- 修复:计划页有任务正在计时时,汇总行会触发未定义函数导致整页报错。
|
||||
- 修复:设置页导出工作记录 CSV 缺少分类标签导入,触发即报错。
|
||||
- 修复:记账弹窗切换收支类型时引用了不存在的分类常量,触发即报错。
|
||||
- 记录弹窗标题与按钮文案随「新增 / 编辑」模式正确切换。
|
||||
|
||||
## 升级与部署
|
||||
|
||||
- 桌面端:安装 InternLog-Setup-0.6.0.exe。
|
||||
- 安卓端:安装 InternLog-0.6.0-debug.apk。
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端 bundle 随仓库更新)。
|
||||
- 数据结构无变更,v0.5.0 及之前版本可直接升级。
|
||||
@@ -0,0 +1,18 @@
|
||||
# v0.6.1 发布说明(2026-10-26)
|
||||
|
||||
## 数据安全:服务器数据纪元保护(重要)
|
||||
|
||||
- 服务端为每个用户的数据文件记录「数据纪元」(首次创建时间),随同步接口返回。
|
||||
- 客户端检测到纪元变化(说明服务器数据存储被重建,例如误删 data 目录后重启)时,自动作废本地同步镜像,**把本机全量数据重新推回服务器**,避免「服务器空了 → 拉到空快照 → 本地缓存也被清空」的连锁数据丢失。
|
||||
- 也就是说:只要还有任何一台设备保留着本地缓存,升级到本版本后第一次同步就会把数据恢复到服务器。
|
||||
- 服务器部署文档已移除危险的 `rm -rf data` 步骤,并明确标注 data 目录为正式数据、严禁删除。
|
||||
|
||||
## 功能修正
|
||||
|
||||
- 详情页导出:「当日」改为「指定日」——导出的是详情页正在浏览的那一天;切换浏览日期时,导出区间(周/月/自定义)锚点自动跟随。
|
||||
- 新增**全局计时时钟面板**:计划开始计时后,任何页面右下角都会悬浮显示计时任务(脉冲动画 + 实时走秒 + 一键停止),点击可跳回计划页;手机端悬浮于底部标签栏上方。
|
||||
|
||||
## 升级
|
||||
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(**切勿删除 server/data 目录**)
|
||||
- 桌面 / 安卓:安装对应 0.6.1 安装包。
|
||||
@@ -0,0 +1,18 @@
|
||||
# v0.6.2 发布说明(2026-10-26)
|
||||
|
||||
## 数据安全:补上「首次升级即遇灾」的恢复漏洞(关键)
|
||||
|
||||
- v0.6.1 的数据纪元保护只对「已存过纪元」的设备生效;所有设备第一次跑新版时检测不到服务器数据被重建。
|
||||
- 本版本增加**二道防线**(不依赖任何历史状态):若本地镜像认为服务器应有 N 行,而服务器实际行数更少——即服务器数据发生过丢失/重建——立即作废本地镜像,把本机全量数据重推回服务器,再拉取快照,本地与服务器同时恢复。
|
||||
- 已通过灾难恢复冒烟测试(7/7):删除服务器用户数据文件后,保留缓存的设备下一次同步自动重推全部数据(备忘录、密码库密文、主密码盐与校验值、考勤规则),全新设备随后完整拉回。
|
||||
|
||||
## 与数据丢失相关的说明
|
||||
|
||||
- 密码库:密文行与主密码盐/校验值均随同步存储于服务器;只要任一快照存活即可整体恢复,主密码不变。
|
||||
- 备忘录:明文行随同步存储,恢复机制相同。
|
||||
- 若所有设备都已在数据丢失后打开过应用(本地缓存同样被旧版清空),且服务器 data 目录被删、COS 未配置,则丢失部分无法找回;建议尽快配置 COS 异地备份。
|
||||
|
||||
## 升级
|
||||
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(切勿删除 server/data)
|
||||
- 桌面 / 安卓:安装 0.6.2;网页端刷新页面即为新版。
|
||||
@@ -0,0 +1,26 @@
|
||||
# v0.6.3 发布说明(2026-10-26)
|
||||
|
||||
服务端存储层加固(纯服务端变更,客户端无需重装;桌面/安卓继续使用 0.6.2 安装包)。
|
||||
|
||||
## 存储可靠性
|
||||
|
||||
- **原子落盘**:所有 JSON 写入改为「临时文件 + fsync + 原子改名」,进程崩溃/断电不会留下半截数据文件
|
||||
- **损坏隔离**:用户数据文件若解析失败,自动隔离为 `.corrupt-<时间戳>` 保留原件并在日志报错——绝不静默当作空数据(旧逻辑的静默空文件正是数据事故的放大器)
|
||||
- **墓碑自动回收**:删除标记保留 14 天后自动清除,数据文件不再随历史无限膨胀,也不会永久重放旧删除
|
||||
- **空同步不落盘**:内容无变化的同步不再重写磁盘(顺带修复 cursor 假值导致的每轮必写)
|
||||
|
||||
## 备份体系
|
||||
|
||||
- **当日首份快照不可变**:同日再备份写入带时间戳的副本目录,当日证据链不再被覆盖(本次事故中 11:11 快照被 20:03 覆盖正是教训)
|
||||
- **每周深快照**:每周一额外保留一份,保留 8 份
|
||||
- **大批删除前置保险**:单次同步携带 ≥20 条墓碑时,服务端先自动快照再应用删除,可随时回滚
|
||||
- `/api/health` 新增 users / lastBackup 字段
|
||||
|
||||
## 验证
|
||||
|
||||
- 服务端协议审计 36/36、用户管理审计 16/16、灾难恢复自救 7/7、存储加固冒烟 9/9 全部通过
|
||||
- 修复审计中两处过时预期(墓碑 GC 后老日期墓碑不再回传属预期;当日首份快照布局变更)
|
||||
|
||||
## 部署
|
||||
|
||||
`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.6.4 发布说明(2026-10-26)
|
||||
|
||||
## 移动端布局与板块尺寸优化
|
||||
|
||||
**触控基线**(全部界面统一):
|
||||
- 常规按钮 36px / 次级 32px / 小按钮 30px(原 24-28px,触控不友好)
|
||||
- 输入框与下拉 38px 高;分段选择器 34px;图标按钮 34px
|
||||
- 弹窗底部主按钮 40px 并等宽铺满;弹窗宽距收窄近全屏
|
||||
|
||||
**板块重排**:
|
||||
- 详情页:出勤信息改 2×2 紧凑网格(原手机上拉成 1 列过高);打卡/休假按钮并排等宽
|
||||
- 计划页:每日任务操作按钮(计时/完成/跳过)等宽铺满、触控高度 32px;「今天 / 添加计划」等宽;长期计划卡操作角标化
|
||||
- 记账页:工具栏分区——月份导航一行、两个筛选并排、「记一笔」全宽主按钮
|
||||
- 详情页导出:指定日/当周/当月/自定义分段等宽铺满,日期输入全宽
|
||||
- 总结页:范围分段铺满一行;草稿编辑器高度收窄
|
||||
- 备忘录:列表收窄至 210px,底部按钮等宽
|
||||
- 底部标签栏加高至 56px、图标 22px;悬浮计时时钟位置同步适配
|
||||
- 顶栏打卡按钮加高至 34px,更易点按
|
||||
|
||||
## 部署
|
||||
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
|
||||
- 桌面 / 安卓:安装 0.6.4 安装包
|
||||
@@ -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 随仓库分发,服务器克隆后一次构建全部完成
|
||||
@@ -1,4 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
repro/*.cjs
|
||||
*.local
|
||||
android/.gradle/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/local.properties
|
||||
server/data/
|
||||
|
||||
@@ -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 运行,命令见文件头注释)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
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);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appId": "cn.linyux.internlog",
|
||||
"appName": "实习工作日志",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"androidScheme": "https"
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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 |
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
saveFile: (filename, content) => ipcRenderer.invoke('export-file', { filename, content })
|
||||
})
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
electron: true
|
||||
|
||||
# 允许执行安装脚本的依赖(esbuild 需要准备平台二进制)
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- electron
|
||||
|
||||
@@ -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) })
|
||||
@@ -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) })
|
||||
@@ -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) })
|
||||
@@ -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) })
|
||||
@@ -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) })
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -112,44 +112,7 @@ function save() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(29, 33, 41, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 90;
|
||||
}
|
||||
.dialog {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: var(--bg-page);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
|
||||
.dialog-body { padding: 16px 20px; }
|
||||
.dialog-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
|
||||
.field.span2 { grid-column: span 2; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.dialog { width: 480px; }
|
||||
.pwd-row { display: flex; gap: 8px; }
|
||||
.pwd-row .input { flex: 1; }
|
||||
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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"/>')
|
||||
}
|
||||
|
||||