feat(v1.11.5-L): lite 精简版——移除大屏/客户自查/多用户/小键盘,套餐仅保留按日

需求与确认(用户逐项确认):
- 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式
- 打手归属一并移除(只剩一个人,颜色与指派没有区分意义)
- 盲水印做成完全不可见:只存在于 DOM / 源码,肉眼与截图都看不到
- 拼写采用更正后的 Powered by Baka春秋
- 版本号与品牌全站统一带 -L
- screen_public / portal_public 保留解析但不起作用,保证旧 ini 能直接启动
- README 同步更新

品牌与版本(全站统一):
- const version = "1.11.5-L":<title>、启动横幅、日志前缀 [代打台-L]、
  设置页版本号、页脚版本号全部带 -L
- 左上角导航品牌 代打台 → 代打台-L(登录页、初始化页同步)
- 页脚新增可见署名 Powered by Baka春秋

盲水印:
- _layout.html 在 <body> 起始处注入 .wm-blind 容器(含注释与 data-wm 双标记),
  覆盖包括登录/初始化在内的所有页面
- app.css 用零尺寸 + 透明 + opacity:0 + clip-path:inset(50%) 四重隐藏,
  同时留同名注释;不占位、不上色、不进无障碍树、不影响打印

功能移除:
- 大屏:删除 handlers_screen.go、pages/screen.html、_screen_layout.html、
  static/screen.css、static/screen.js、store.Stats/ScreenStats;
  导航「大屏 ↗」、页脚「大屏」、设置页「大屏刷新/大屏」行、启动横幅大屏行一并删除
- 客户自查:删除 handlers_portal.go、pages/portal.html 与 .portal-*/.pcal-* 样式,
  设置页「客户自助」行删除
- 多管理员:删除 handleUserCreate/Toggle/Color/UserEditPage/UserEditSave、
  pages/user_form.html、store 侧 ActiveUsers/SetUserColor/SetUserActive/
  UpdateUserByAdmin/NormalizeColor/AssigneePalette;设置页「管理员」整块移除,
  只保留首次启动创建的那一个账户与「修改我的密码」
- 打手归属:删除 customers.assignee_id 相关读写与 UI(客户详情指派下拉、
  客户表单「归属打手」、日历格左侧打手色竖条、任务卡打手标签、套餐详情客户列)
- 登录页与初始化页的说明文案同步改为单账户口径(「只有一个管理员账户」
  「创建后不能再添加其他管理员」),字数与 main 对齐,卡片不换行、高度不变
- 触摸小键盘:删除 3×4 键盘、⌨/触控开关、localStorage 记忆、.keypad* 样式
- 套餐计费方式:只保留按日。删除按次/通行证/深渊的 Package 与 Subscription 字段、
  domain 里的 UnitCount/StateUsedUp/CycleIndex、package_form 计费方式单选与
  通行证/深渊/有效期字段、日历整段色块(has-block/cell-block/season-band)、
  追加次数分支、SeasonComplete/AddCount、CountActiveSubs 的次数条件

数据库迁移(幂等,Migrate 内自动执行):
- normalizeLegacyData:套餐一律 unit='day' 并清 season/abyss/cycle_days/
  default_validity_days/season_*;订阅清 season/abyss/cycle_days/season_done
- 通行证、深渊当年就是 unit='day' 且起止日完整,仅清标记,服务区间原样保留
- 按次按「1 次 = 1 天」折算:结束日 = 开始日 + 总次数 − 1;仍缺结束日退回开始日
- 只动数据,不删表不删列:老库的 season/abyss/cycle_days/total_count/used_count
  保留但不再使用,升级可逆、不会因为 DDL 失败卡住
- 新库 schema 干净:不再创建上述列,users.color / customers.assignee_id 也不创建
- 老库 ensureColumn 保留 calendar_separate / tag / archived 三项(真增量列)

兼容:
- config 仍解析 screen_public / portal_public(结构体字段注明已废弃),
  默认 ini 模板里改为「已废弃,写了不报错、不生效」

测试:
- go vet ./... 通过;go test ./... 全部通过
  (新增 TestNormalizeLegacyTypeData 验证三模式折算成按日;
   TestSingleUserAccount / TestSessionLifecycle 覆盖单用户与会话;
   删除按次、深渊、打手颜色、多用户等已不存在的能力的用例)
- 新增 scripts/smoke-lite.ps1:起隔离实例,19 个页面级请求全部 200,
  已移除的 /screen、/customer、/api/screen/day 均 404,
  水印/品牌/v1.11.5-L/无残留词 12 项断言通过,
  真实数据下日历出现待打任务、「剩 30 天」、今天到期徽标 4 项断言通过,
  本次 0 失败

README:改为 lite 版说明,删除大屏/客户自查/通行证/深渊/按次/小键盘/多用户章节,
新增「盲水印」与「与 main 分支的差异」两节(含老数据折算规则与列保留说明)。
This commit is contained in:
2026-10-21 03:33:27 +08:00
parent 0392f44cf1
commit cde1c84c9a
45 changed files with 786 additions and 4391 deletions
+88 -92
View File
@@ -1,8 +1,12 @@
# 代打台 · 代打管理平台 # 代打台-L · 代打管理平台(lite 版)
一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡 → 大屏**。 一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡**。
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。界面支持亮色 / 暗色切换,默认跟随系统。 整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。界面支持亮色 / 暗色切换,默认跟随系统。
> **这是 lite 分支**:只保留「按日(包时)」一种套餐,去掉了大屏、客户自助查询、多管理员、触摸小键盘。
> 版本号统一带 `-L` 后缀(当前 `1.11.5-L`),界面品牌为「代打台-L」,页脚署名 `Powered by Baka春秋`。
> 与 `main` 分支的差异见文末「[10. 与 main 分支的差异](#10-与-main-分支的差异)」。
--- ---
## 1. 快速开始 ## 1. 快速开始
@@ -12,20 +16,18 @@
- 首次运行会自动生成配置文件 `daili.ini`; - 首次运行会自动生成配置文件 `daili.ini`;
- 弹出一个黑色窗口,打印访问地址(默认 **17777** 端口); - 弹出一个黑色窗口,打印访问地址(默认 **17777** 端口);
- 默认会自动打开浏览器; - 默认会自动打开浏览器;
3. 浏览器打开 `http://127.0.0.1:17777` → 第一次会让你**创建管理员账号**; 3. 浏览器打开 `http://127.0.0.1:17777` → 第一次会让你**创建管理员账号**(这是唯一的账号,之后不能再添加);
4. 之后按「套餐 → 客户 → 订阅」的顺序录入即可。 4. 之后按「套餐 → 客户 → 订阅」的顺序录入即可。
``` ```
┌───────────────────────────────────────────────┐ ┌───────────────────────────────────────────────┐
│ 代 打 台 · 代打管理平台 │ │ 代 打 台 - L · 代打管理平台 │
└───────────────────────────────────────────────┘ └───────────────────────────────────────────────┘
版本 1.11.5 配置文件 daili.ini 版本 1.11.5-L 配置文件 daili.ini
数据库 D:\代打台\daili.db 数据库 D:\代打台\daili.db
访问地址: 访问地址:
本机 http://127.0.0.1:17777 本机 http://127.0.0.1:17777
局域网 http://192.168.1.23:17777 局域网 http://192.168.1.23:17777
大屏 http://127.0.0.1:17777/screen(免登录)
注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen
按 Ctrl+C 停止服务 按 Ctrl+C 停止服务
``` ```
@@ -48,18 +50,19 @@
| `backup_keep` | `14` | 备份保留份数(1–365),超出自动清理 | | `backup_keep` | `14` | 备份保留份数(1–365),超出自动清理 |
| `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** | | `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** |
| `open_browser` | `true` | 启动后自动打开浏览器 | | `open_browser` | `true` | 启动后自动打开浏览器 |
| `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 |
| `portal_public` | `true` | 客户自助查询 `/customer` 免登录;改 `false` 则关闭该页(不影响大屏) |
| `log_enabled` | `true` | 运行日志写文件(控制台始终输出) | | `log_enabled` | `true` | 运行日志写文件(控制台始终输出) |
| `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` | | `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` |
> `screen_public`(大屏)与 `portal_public`(客户自助)在 lite 版**已废弃**:仍然接受解析,但不起任何作用。
> 保留它们只是为了让你从 `main` 升级时,旧 `daili.ini` 能直接启动,不会被「未知配置项」拦住。
**行为约定** **行为约定**
- 文件不存在 → 自动生成默认配置,照常启动; - 文件不存在 → 自动生成默认配置,照常启动;
- 文件写错 → **报错退出并指出行号**,原文件不会被修改。例如: - 文件写错 → **报错退出并指出行号**,原文件不会被修改。例如:
``` ```
[代打台] 配置文件有问题,已停止启动: [代打台-L] 配置文件有问题,已停止启动:
第 3 行: listen_port 必须在 1-65535 之间,当前是 99999 第 3 行: listen_port 必须在 1-65535 之间,当前是 99999
修好后重新启动即可(原文件未被修改) 修好后重新启动即可(原文件未被修改)
@@ -81,31 +84,31 @@
示例:`daili.exe -addr 127.0.0.1:19000 -no-open` 示例:`daili.exe -addr 127.0.0.1:19000 -no-open`
> 业务参数(到期预警天数、大屏刷新秒数)不在配置文件里,仍在网页「设置」中修改。 > 业务参数(到期预警天数)不在配置文件里,仍在网页「设置」中修改。
--- ---
## 3. 第一次使用(5 分钟走完) ## 3. 第一次使用(5 分钟走完)
1. **建套餐**:`套餐 → 新增套餐` 1. **建套餐**:`套餐 → 新增套餐`
- 「包月日常」按日 30 天,色签绿; - 「包月日常」默认 30 天,色签绿;
- 「深渊十次」按次 10 次,色签紫。 - 只有「按日」一种计费方式:有开始日与结束日,每天一条待打任务。
> 以后要加套餐 D/E/F,直接再加一行就行,**不会影响任何已有客户数据**。 > 以后要加套餐 D/E/F,直接再加一行就行,**不会影响任何已有客户数据**。
2. **建客户**:`客户 → 新建客户`,填手机尾号 + 客户名(这两项组合唯一)。 2. **建客户**:`客户 → 新建客户`,填手机尾号 + 客户名(这两项组合唯一)。
3. **开通订阅**:进客户详情 → 选套餐、开始日、天数(按次则填次数)→ 开通订阅。 3. **开通订阅**:进客户详情 → 选套餐、开始日、天数 → 开通订阅。
4. **日常使用**:打开「日历」,今天那格里就是这个客户今天要打的单,点「完成」打卡。 4. **日常使用**:打开「日历」,今天那格里就是这个客户今天要打的单,点「完成」打卡。
5. **续费**:客户转账后,点订阅上的「追加时长」→ 在日历上点新的终止日期 → 确认。 5. **续费**:客户转账后,点订阅上的「追加时长」→ 在日历上点新的终止日期 → 确认。
面板会实时显示「计算起点 / 原结束日 / 新结束日 / 本次追加几天 / 追加后剩余几天」,并在客户页留下追加记录。 面板会实时显示「计算起点 / 原结束日 / 新结束日 / 本次追加几天 / 追加后剩余几天」,并在客户页留下追加记录。
> 想先看效果再录自己的数据?程序启动后运行 `powershell -ExecutionPolicy Bypass -File scripts\seed.ps1`,会写入 8 个演示客户和 4 个套餐(默认账号 `admin` / `daili123`)。 > 想先看效果再录自己的数据?程序启动后运行 `powershell -ExecutionPolicy Bypass -File scripts\seed.ps1`,会写入演示客户与套餐(默认账号 `admin` / `daili123`)。
--- ---
## 4. 业务规则(和 PRD 一致) ## 4. 业务规则(和 PRD 一致)
**剩余时长** **剩余时长**
- 按日:`剩余天数 = 结束日 − 今天 + 1`,结束日当天仍算服务日; - `剩余天数 = 结束日 − 今天 + 1`,结束日当天仍算服务日;
- 按次:`剩余次数 = 总次数 − 已用次数`;按次套餐的「有效期」可留空,留空就一直有效直到次数用完。 - 没有次数、没有周期:一个订阅就是从开始日到结束日的一段连续服务期。
**追加时长(核心)** **追加时长(核心)**
- 打开追加面板,**在月历上点选新的终止日期**,系统自动反算; - 打开追加面板,**在月历上点选新的终止日期**,系统自动反算;
@@ -115,15 +118,14 @@
**暂停 / 恢复** **暂停 / 恢复**
- 暂停期间冻结剩余时长,日历上不出现待打任务; - 暂停期间冻结剩余时长,日历上不出现待打任务;
- 恢复时按日套餐的结束日**自动顺延**暂停的自然日天数,时间轴上是灰色斜纹段。 - 恢复时结束日**自动顺延**暂停的自然日天数,时间轴上是灰色斜纹段。
**打卡** **打卡**
- 按日套餐:每天一条「完成 / 跳过」,只做记录不扣天数(天数按自然日流逝); - 每天一条「完成 / 跳过」,只做记录不扣天数(天数按自然日流逝);
- 按次套餐:打卡「完成」自动扣 1 次,撤销打卡返还 1 次;次数为 0 时不允许再打; - 撤销打卡会删掉这条记录,不影响结束日。
- 按次套餐如果没设有效期,它不会出现在日历里 —— 直接在客户详情点订阅卡片上的「今日打卡」扣次。
**预警** **预警**
- 剩余 ≤3 天橙色、今天到期红色、已过期灰色;顶栏常驻「今日到期 / 3 天内」两个数字。 - 剩余 ≤3 天橙色、今天到期红色、已过期灰色;顶栏常驻「今日到期 / 3 天内 / 7 天内」三个数字。
**查询** **查询**
- 顶栏搜索框输入手机尾号即可定位客户(也支持姓名、游戏名、联系方式),按 `/` 快速聚焦。 - 顶栏搜索框输入手机尾号即可定位客户(也支持姓名、游戏名、联系方式),按 `/` 快速聚焦。
@@ -135,51 +137,24 @@
- **按尾号快速完成**:日历右侧「当日待完成」顶部输入手机尾号 → 回车。 - **按尾号快速完成**:日历右侧「当日待完成」顶部输入手机尾号 → 回车。
- 只匹配到 1 项:直接完成并提示; - 只匹配到 1 项:直接完成并提示;
- 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。 - 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。
- **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。 - **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点。再点一次取消。
- **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。 - **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。
- **触摸小键盘**:日历右侧输入框下面有 3 列 4 行数字键盘,底行是 ⌫ / 0 / ✓,点 ✓ 直接提交;按键为自定义样式,不是浏览器原生按钮,触摸目标 52px 高; - **日历格子**:只保留大字「已完成 / 总数」(如 1/2 = 2 单里做完 1 单)。
- **日历格子**:不再显示排单缩略信息,只保留左侧打手色竖条 + 大字「待完成 / 总数」(如 1/2 = 还剩 1 单,共 2 单); - **按钮收纳**:任务行只露出「完成」,跳过 / 加急 / 追加收进「⋯」菜单,点空白处自动收起。
- **按钮收纳**:任务行只露出「完成」,跳过 / 加急 / 追加收进「⋯」菜单,点空白处自动收起; - **停用客户**:客户详情页可「停用」(默认从列表与日历隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复;
- **停用客户**:客户详情页可「停用」(默认从列表、日历、大屏隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复; - **7 天内到期**:顶栏徽标,固定统计未来 7 天内到期的订阅;
- **7 天内到期**:顶栏新增徽标,固定统计未来 7 天内到期的订阅; - **抽屉时钟**:右侧当日面板顶部有时钟,24 小时制精确到秒,下面一行是当天日期;
- **抽屉时钟**:右侧当日面板顶部有时钟,30px 大写、水平垂直居中,24 小时制精确到秒,下面一行是当天日期; - **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余,以及撤销/加急/追加;未完成的行不受影响;
- **触控 / PC 模式**:尾号输入框右侧有「⌨ / 触控」开关。切到触控时展开 3×4 小键盘、输入框变只读并关闭系统输入法(`inputmode=none`),点键直接写值;切回 PC 收起键盘。选择记在浏览器里。
- **格子计数**:工作台和大屏的日历格都显示「已完成 / 总数」,例如 2/5 = 5 单里做完 2 单。
- **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余、打手,以及撤销/加急/追加;未完成的行不受影响;
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
- 「当日待完成」每条前面有打手色条 + 名字,未指派的显示灰色「未指派」;
- 大屏的今日待打、悬停浮层、热力格竖条同样带颜色。
- **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;没选过时由页面在首屏按系统偏好自动决定。整套界面只认 `<html data-bs-theme>` 一个开关,不会出现「半亮半暗」。 - **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;没选过时由页面在首屏按系统偏好自动决定。整套界面只认 `<html data-bs-theme>` 一个开关,不会出现「半亮半暗」。
- **升级不缓存旧界面**:CSS/JS 链接带**内容指纹**(`app.css?v=<12 位哈希>`)。资源可以长缓存(一年、immutable),但样式或脚本一改、重新编译,URL 就会变,浏览器自动取新文件,不用手动清缓存;HTML 仍然 `no-store`。 - **升级不缓存旧界面**:CSS/JS 链接带**内容指纹**(`app.css?v=<12 位哈希>`)。资源可以长缓存(一年、immutable),但样式或脚本一改、重新编译,URL 就会变,浏览器自动取新文件,不用手动清缓存;HTML 仍然 `no-store`。
--- ---
## 4.4 客户自助查询 /customer
- **独立开关**:`/customer` 由 `portal_public` 控制(默认 `true`),和大屏 `screen_public` 互不影响;关掉大屏不会连带关掉客户页。
- **查法**:只接受 4–8 位数字手机尾号,精确匹配;同一尾号有多位客户时要求补姓名,并用「张* / Z***」这样的掩码提示,不直接列全名;每个 IP 每分钟最多 40 次查询。
- **展示**:今日待完成数量、订阅列表(通行证 / 深渊 / 按次 / 按日,含已到期、暂停、已用尽状态)、当月排单。月历里日常任务用空心/实心点表示未完成/已完成,通行证和深渊用连续色块并显示 `1/1` 或 `1/4 周期`;前后月只做日期占位。页面不含备注与价格。
- **隐私**:页面 `noindex`,HTML `no-store`;只显示排单与完成情况。若不需要客户自助,把 `portal_public` 改成 `false`。
---
## 4.3 通行证、深渊与按次有效期
- **通行证**(原「包赛季」):套餐上直接填通行证开始日/结束日。客户开通后,该套餐的独立日历会把整段有效期连续着色(用套餐色签的淡化色),每一格显示同一个「已完成 / 总单数」(0/1 或 1/1)。有效期内任意一天点「完成通行证」即整段完成,完成后色块保留并显示 1/1。
- **深渊**:套餐填「周期天数 N」与「周期数 X」。开通时从开始日连续排出 X 个 N 天周期;独立日历按周期连续着色,颜色从约 34% 依次减淡到约 4%。每一格显示**该周期自己**的完成情况:`1/1` 或 `0/1`,不再重复全局的 `x/X`;工作台、大屏、客户自助页同一口径。周期内任意一天完成即算该周期完成(同周期重复完成不会重复计数),撤销该天打卡会退回该周期。
- **通行证过期不可选**:今天已过通行证结束日的套餐,在客户开通下拉里会置灰并标「通行证已结束」,服务端也会拒绝开通。
- **按次默认有效期**:按次套餐可设「默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可手动改。
- **删除订阅**:已结束的订阅卡片上,在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加记录。
---
## 4.2 套餐标签与批量开通 ## 4.2 套餐标签与批量开通
- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。 - **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。
- **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数/次数后一次开通;重复的会自动跳过并提示。 - **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数后一次开通;重复的会自动跳过并提示。
- **客户月历**:客户详情页新增「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。 - **客户月历**:客户详情页「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。
- **按次到期**:按次订阅的「到期日」仍是选填;填了到期后状态显示「已到期」、不再进日历待排。
--- ---
@@ -194,8 +169,9 @@
### 迁移与重置(只说一次,很重要) ### 迁移与重置(只说一次,很重要)
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。 - **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。 - **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建管理员」的全新状态。
- 通行证是 `packages.season/season_start/season_end` 与 `subscriptions.season/season_done`;深渊是 `packages.abyss/cycle_days` 与 `subscriptions.abyss/cycle_days`(`total_count` 存周期数);套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; - 套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);加急标记存在 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移。
- **从 main 升级**:lite 版启动时会自动把老库里的「按次 / 通行证 / 深渊」统一折算成按日(详见文末差异说明),老库直接打开即可,无需手工改数据。
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。 - `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库; - 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
@@ -203,22 +179,7 @@
--- ---
## 6. 大屏 ## 6. 从源码构建
- 地址:`http://<本机IP>:17777/screen`
- 默认 `screen_public = true`:**免登录**,电视/副屏直接打开即可(仅建议局域网使用);
- 改成 `screen_public = false` 后大屏回到登录保护;
- 「记分板」风格,亮 / 暗主题同样可切(右上角 ☾ / ☀):
- 顶部 KPI:客户 / 活跃订阅 / 今日到期 / 7 天内到期;
- 中部:**热力月历 + 即将到期 TOP10**。热力默认统计「每日待完成单量」,点右上角按钮可切回「到期热力」;鼠标悬停任意格子会浮出当日待办明细(含加急标记);
- 底部:**今日到期**(左)+ **今日待打**(右,加急置顶带红 tag);
- 最下方细条:最近追加流水;
- 套餐勾选「在日历中独立显示」后,大屏顶部会出现套餐标签,可切换只看某类委托;
- 默认 30 秒自动刷新(设置页可改),按 `F` 全屏。
---
## 7. 从源码构建
环境:Windows + Go 1.25(本项目在 `go1.25.6` 上验证)。 环境:Windows + Go 1.25(本项目在 `go1.25.6` 上验证)。
@@ -243,6 +204,12 @@ $env:GOFLAGS='-mod=mod'; $env:GOPROXY='off'; $env:GOCACHE="$PWD\.gocache"
go test ./... go test ./...
``` ```
页面级冒烟(起一个隔离实例,把所有页面渲染一遍,并校验水印/品牌/已移除入口):
```powershell
powershell -ExecutionPolicy Bypass -File scripts\smoke-lite.ps1
```
目录结构: 目录结构:
``` ```
@@ -254,10 +221,10 @@ internal/logx 控制台 + 按天切分的运行日志
internal/pw 口令散列(PBKDF2-SHA256,标准库实现) internal/pw 口令散列(PBKDF2-SHA256,标准库实现)
internal/web 路由、会话、模板渲染、处理器 internal/web 路由、会话、模板渲染、处理器
internal/web/templates 页面与布局(embed 进二进制) internal/web/templates 页面与布局(embed 进二进制)
internal/web/static app.css / screen.css / app.js / screen.js internal/web/static app.css / app.js
scripts/acceptance.ps1 冒烟清单执行器(119 条断言) scripts/smoke-lite.ps1 lite 版页面级冒烟(渲染 + 水印 + 已移除入口 404)
scripts/config-test.ps1 配置文件专项测试(14 条断言) scripts/acceptance.ps1 冒烟清单执行器(main 分支遗留,部分条目已不适用)
scripts/smoke.ps1 端到端冒烟(早期版本,保留) scripts/config-test.ps1 配置文件专项测试
scripts/seed.ps1 写入演示数据 scripts/seed.ps1 写入演示数据
scripts/dbtool.py 测试预处理(暂停回溯、批量造数、完整性检查) scripts/dbtool.py 测试预处理(暂停回溯、批量造数、完整性检查)
.gitea/workflows/build.yml Gitea Actions:vet → test → 交叉编译 → 上传产物 .gitea/workflows/build.yml Gitea Actions:vet → test → 交叉编译 → 上传产物
@@ -283,7 +250,7 @@ daili.ini 首次运行自动生成
--- ---
## 8. 常见问题 ## 7. 常见问题
**Windows 提示「已保护你的电脑」** **Windows 提示「已保护你的电脑」**
点「更多信息 → 仍要运行」。这是 Windows 对未签名 exe 的默认拦截。 点「更多信息 → 仍要运行」。这是 Windows 对未签名 exe 的默认拦截。
@@ -301,26 +268,55 @@ daili.ini 首次运行自动生成
4. 用启动时打印的那个 `192.168.x.x` 地址,不要用 `127.0.0.1`。 4. 用启动时打印的那个 `192.168.x.x` 地址,不要用 `127.0.0.1`。
**忘记密码** **忘记密码**
让另一位管理员在 `设置 → 管理员` 里新增一个账户即可;如果只剩一个管理员且忘了密码,只能删掉 `daili.db` 重来。 lite 版只有一个管理员,没有「新增管理员」可以绕过,只能二选一:删掉 `daili.db` 从零开始,或用任意 SQLite 工具把 `users` 表的 `password_hash` 清空后重新初始化。请务必记牢密码。
**数据会不会因为程序崩溃丢?** **数据会不会因为程序崩溃丢?**
单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。 单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。
**能放到公网吗?** **能放到公网吗?**
不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏和客户自助页默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public`、`portal_public` 改成 `false`。 不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定。要放公网请套一层反向代理 + HTTPS + 访问控制。
--- ---
## 9. 安全说明 ## 8. 安全说明
- **初始管理员**(第一个创建的账号,`id=1`)在 `设置 → 管理员` 每行可以点「编辑」,修改其他管理员的用户名、昵称,或重置密码;
- 重置密码会立刻清除对方的登录会话,他需要用新密码重新登录;昵称/用户名的修改不影响会话;
- 自己那一行不显示编辑(改自己的密码用「修改我的密码」),避免误操作把自己锁在门外;
- 其余管理员权限相同,但只能改自己的密码。
- lite 版**只有一个管理员**,就是首次启动时创建的那个账户(`users.id = 1`),不提供新增、停用或编辑他人账户的入口;
- 改自己的密码用 `设置 → 修改我的密码`;
- 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆; - 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆;
- 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询; - 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询;
- 服务端模板全部走 `html/template` 自动转义; - 服务端模板全部走 `html/template` 自动转义;
- 所有 POST 校验 `Origin`,会话 Cookie 使用 `SameSite=Lax` + `HttpOnly`; - 所有 POST 校验 `Origin`,会话 Cookie 使用 `SameSite=Lax` + `HttpOnly`;
- 配置文件里的数据库/备份/日志路径请自行确保目录权限,程序不会覆盖已有文件(备份重名会跳过)。 - 配置文件里的数据库/备份/日志路径请自行确保目录权限,程序不会覆盖已有文件(备份重名会跳过)。
---
## 9. 盲水印
界面里带一处不可见的版权水印 `Powered by Baka春秋`:写在 `_layout.html` 里由 `.wm-blind` 样式隐藏(零尺寸、透明、`clip-path` 裁掉、不进入无障碍树),同时在 `app.css` 里留了同名注释。它不占位、不上色、不影响布局与打印,只用于源码/DOM 层面溯源,**请勿删除**。
页脚另有一行可见署名 `Powered by Baka春秋`。
---
## 10. 与 main 分支的差异
| 项目 | main | lite(本分支) |
| --- | --- | --- |
| 版本号 | `1.11.5` | `1.11.5-L`(`<title>`、启动横幅、日志前缀、页脚全站统一带 `-L`) |
| 品牌 | 代打台 | 代打台-L |
| 大屏 `/screen` | 有,`screen_public` 控制免登录 | **已移除**,路由返回 404 |
| 客户自助 `/customer` | 有,`portal_public` 控制免登录 | **已移除**,路由返回 404 |
| 套餐计费方式 | 按日 / 按次 / 通行证 / 深渊 | **只有按日** |
| 管理员 | 可新增多个,为主管可编辑他人 | **只有首次启动创建的那一个** |
| 打手归属 | 客户可指派打手,彩色竖条标识 | **已移除**(只剩一个人,颜色无意义) |
| 触摸小键盘 | 3×4 小键盘 + 触控/PC 切换 | **已移除** |
| 署名 | 无 | 页脚 + 盲水印 `Powered by Baka春秋` |
**老数据怎么折算成按日**(启动时自动完成,可重复执行):
- **通行证**:当年就是 `unit='day'` 且起止日期完整,清掉 `season` 标记即可,服务区间原样保留;
- **深渊**:同样已是 `unit='day'`,结束日 = 开始日 + 周期天数 × 周期数 − 1,清掉 `abyss/cycle_days` 标记,区间原样保留;
- **按次**:按「1 次 = 1 天」折算。有总次数但没结束日的,结束日 = 开始日 + 总次数 − 1 天;仍缺结束日的退回开始日;
- 折算只动数据,不删表、不删列:老库里的 `season / abyss / cycle_days / total_count / used_count` 等列会保留但不再使用,避免升级过程不可逆。
`screen_public` 与 `portal_public` 两个配置项在 lite 版仍然被解析(不报错),但不再有任何作用。
+6 -4
View File
@@ -1,7 +1,7 @@
// Package config 负责读取/生成与可执行文件同级的 daili.ini。 // Package config 负责读取/生成与可执行文件同级的 daili.ini。
// //
// 设计约定: // 设计约定:
// - 只放「部署类」参数;业务默认值(预警天数、大屏刷新秒数)仍在设置页(数据库)。 // - 只放「部署类」参数;业务默认值(预警天数等)仍在设置页(数据库)。
// - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。 // - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。
// - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。 // - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。
package config package config
@@ -33,6 +33,8 @@ type Config struct {
BackupKeep int BackupKeep int
Timezone string Timezone string
OpenBrowser bool OpenBrowser bool
// ScreenPublic / PortalPublic 在 lite 版已废弃:仍然解析,但不再有任何作用,
// 只为兼容旧版 daili.ini(否则旧文件会因“未知配置项”直接启动失败)。
ScreenPublic bool ScreenPublic bool
PortalPublic bool PortalPublic bool
LogEnabled bool LogEnabled bool
@@ -279,7 +281,7 @@ func parseBool(v string) (bool, error) {
func templateText(c *Config) string { func templateText(c *Config) string {
return fmt.Sprintf(`# ============================================================ return fmt.Sprintf(`# ============================================================
# 代打台 配置文件(daili.ini) # 代打台-L 配置文件(daili.ini)
# 与 daili.exe 放在同一目录;修改后需要重启程序生效。 # 与 daili.exe 放在同一目录;修改后需要重启程序生效。
# 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。 # 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。
# 相对路径相对于 exe 所在目录,而不是当前工作目录。 # 相对路径相对于 exe 所在目录,而不是当前工作目录。
@@ -312,10 +314,10 @@ timezone = %s
# 启动后自动打开浏览器 # 启动后自动打开浏览器
open_browser = %t open_browser = %t
# 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录) # 以下两项在 lite 版已废弃(本版没有大屏 /screen 与客户自助 /customer)。
# 保留是为了让旧版 daili.ini 仍能直接启动;写了也不报错、不生效。
screen_public = %t screen_public = %t
# 客户自助查询 /customer 是否免登录(默认 true;不需要对外时可改成 false)
portal_public = %t portal_public = %t
# ---------- 日志 ---------- # ---------- 日志 ----------
+1 -1
View File
@@ -46,7 +46,7 @@ func TestLoadCreatesDefaultFile(t *testing.T) {
t.Fatalf("默认配置文件未生成: %v", err) t.Fatalf("默认配置文件未生成: %v", err)
} }
text := string(data) text := string(data)
for _, want := range []string{"listen_ip = 0.0.0.0", "listen_port = 17777", "代打台 配置文件(daili.ini)", "timezone ="} { for _, want := range []string{"listen_ip = 0.0.0.0", "listen_port = 17777", "代打台-L 配置文件(daili.ini)", "timezone ="} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Errorf("默认配置缺少 %q", want) t.Errorf("默认配置缺少 %q", want)
} }
+8 -54
View File
@@ -1,4 +1,7 @@
// Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。 // Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。
//
// lite 版只支持「按日(包时)」一种套餐:订阅永远有开始日与结束日,
// 剩余量永远是天数,因此这里不再有次数、周期、通行证等分支。
package domain package domain
import "time" import "time"
@@ -68,42 +71,12 @@ const (
StatusClosed = "closed" StatusClosed = "closed"
) )
// 套餐类型
const (
UnitDay = "day"
UnitCount = "count"
)
// CycleIndex 返回 date 在 [start, start+cycleDays*cycles) 内的周期序号(0 起)。
// 越界或参数不合法返回 -1。
func CycleIndex(start, date time.Time, cycleDays int) int {
if cycleDays <= 0 {
return -1
}
d := DayDiff(start, date)
if d < 0 {
return -1
}
return d / cycleDays
}
// CycleStart 返回第 idx 个周期的第一天。
func CycleStart(start time.Time, cycleDays, idx int) time.Time {
return AddDays(start, idx*cycleDays)
}
// CycleEnd 返回第 idx 个周期的最后一天。
func CycleEnd(start time.Time, cycleDays, idx int) time.Time {
return AddDays(start, (idx+1)*cycleDays-1)
}
// 派生展示状态 // 派生展示状态
const ( const (
StateActive = "active" // 进行中 StateActive = "active" // 进行中
StatePending = "pending" // 未开始 StatePending = "pending" // 未开始
StatePaused = "paused" // 暂停中 StatePaused = "paused" // 暂停中
StateExpired = "expired" // 已到期 StateExpired = "expired" // 已到期
StateUsedUp = "usedup" // 次数用尽
StateClosed = "closed" // 已结束 StateClosed = "closed" // 已结束
) )
@@ -113,7 +86,7 @@ const (
LevelWarn = "warn" LevelWarn = "warn"
LevelToday = "today" LevelToday = "today"
LevelExpired = "expired" LevelExpired = "expired"
LevelNone = "none" // 无期限(按次且未设有效期) LevelNone = "none" // 无结束日(历史数据兜底)
) )
// Eval 是订阅的计算结果。 // Eval 是订阅的计算结果。
@@ -121,14 +94,12 @@ type Eval struct {
State string State string
DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日 DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日
RemainingDays int RemainingDays int
RemainingCount int
Level string Level string
} }
// EvalSub 计算订阅的展示状态与剩余量。 // EvalSub 计算订阅的展示状态与剩余天数。
// - unit=day:剩余天数 = end - today + 1(含当天) // 剩余天数 = end - today + 1(含当天)。
// - unit=count:剩余次数 = total - used;end 为可选有效期 func EvalSub(status, startDate, endDate string, today time.Time, warnDays int) Eval {
func EvalSub(unit, status, startDate, endDate string, total, used int, today time.Time, warnDays int) Eval {
e := Eval{Level: LevelNone, DaysLeft: -1} e := Eval{Level: LevelNone, DaysLeft: -1}
if warnDays <= 0 { if warnDays <= 0 {
warnDays = 3 warnDays = 3
@@ -142,27 +113,14 @@ func EvalSub(unit, status, startDate, endDate string, total, used int, today tim
} }
} }
if unit == UnitDay {
if hasEnd { if hasEnd {
e.RemainingDays = e.DaysLeft + 1 e.RemainingDays = e.DaysLeft + 1
} }
if e.RemainingDays < 0 { if e.RemainingDays < 0 {
e.RemainingDays = 0 e.RemainingDays = 0
} }
} else {
e.RemainingCount = total - used
if e.RemainingCount < 0 {
e.RemainingCount = 0
}
}
expired := false expired := hasEnd && end.Before(today)
if hasEnd && end.Before(today) {
expired = true
}
if unit == UnitCount && e.RemainingCount <= 0 {
expired = true
}
switch { switch {
case status == StatusClosed: case status == StatusClosed:
@@ -170,11 +128,7 @@ func EvalSub(unit, status, startDate, endDate string, total, used int, today tim
case status == StatusPaused: case status == StatusPaused:
e.State = StatePaused e.State = StatePaused
case expired: case expired:
if unit == UnitCount && e.RemainingCount <= 0 {
e.State = StateUsedUp
} else {
e.State = StateExpired e.State = StateExpired
}
case start.After(today): case start.After(today):
e.State = StatePending e.State = StatePending
default: default:
+9 -30
View File
@@ -46,7 +46,7 @@ func TestEvalDay(t *testing.T) {
{"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30}, {"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30},
} }
for _, c := range cases { for _, c := range cases {
e := EvalSub(UnitDay, c.status, "2026-10-01", c.end, 0, 0, today, 3) e := EvalSub(c.status, "2026-10-01", c.end, today, 3)
if e.State != c.wantState { if e.State != c.wantState {
t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState) t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState)
} }
@@ -61,7 +61,7 @@ func TestEvalDay(t *testing.T) {
func TestEvalDayNotStarted(t *testing.T) { func TestEvalDayNotStarted(t *testing.T) {
today := d(2026, 10, 18) today := d(2026, 10, 18)
e := EvalSub(UnitDay, "active", "2026-10-20", "2026-11-18", 0, 0, today, 3) e := EvalSub("active", "2026-10-20", "2026-11-18", today, 3)
if e.State != StatePending { if e.State != StatePending {
t.Fatalf("state=%s want pending", e.State) t.Fatalf("state=%s want pending", e.State)
} }
@@ -70,37 +70,16 @@ func TestEvalDayNotStarted(t *testing.T) {
} }
} }
func TestEvalCount(t *testing.T) { // TestEvalNoEndDate 覆盖历史数据里结束日为空的兜底分支(lite 版没有按次计费)。
func TestEvalNoEndDate(t *testing.T) {
today := d(2026, 10, 18) today := d(2026, 10, 18)
// 有次数、有有效期,10 天后到期 → 正常 e := EvalSub("active", "2026-10-01", "", today, 3)
e := EvalSub(UnitCount, "active", "2026-10-01", "2026-10-28", 10, 4, today, 3) if e.Level != LevelNone || e.DaysLeft != -1 || e.RemainingDays != 0 {
if e.State != StateActive || e.Level != LevelOK || e.RemainingCount != 6 { t.Fatalf("level=%s daysLeft=%d remain=%d", e.Level, e.DaysLeft, e.RemainingDays)
t.Fatalf("got state=%s level=%s remain=%d", e.State, e.Level, e.RemainingCount)
} }
if e.State != StateActive {
// 有效期就是今天 → 今天到期(而不是误判成 warn/ok) t.Fatalf("state=%s want active", e.State)
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-18", 10, 4, today, 3)
if e.Level != LevelToday {
t.Fatalf("level=%s want today", e.Level)
}
// 只有次数,没有有效期 → 无预警等级
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 4, today, 3)
if e.Level != LevelNone || e.DaysLeft != -1 {
t.Fatalf("level=%s daysLeft=%d", e.Level, e.DaysLeft)
}
// 次数用尽
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 10, today, 3)
if e.State != StateUsedUp {
t.Fatalf("state=%s want usedup", e.State)
}
// 次数还有,但有效期已过
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-10", 10, 4, today, 3)
if e.State != StateExpired {
t.Fatalf("state=%s want expired", e.State)
} }
} }
+12 -133
View File
@@ -20,7 +20,7 @@ type Checkin struct {
UpdatedAt string UpdatedAt string
} }
// SaveCheckin 打卡(同订阅同日覆盖),按次套餐完成时扣次。 // SaveCheckin 打卡(同订阅同日覆盖)。
func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error { func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error {
if status != "done" && status != "skipped" { if status != "done" && status != "skipped" {
return errors.New("打卡状态不正确") return errors.New("打卡状态不正确")
@@ -36,133 +36,33 @@ func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int
if s.Status == domain.StatusClosed { if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法打卡") return errors.New("订阅已结束,无法打卡")
} }
var oldStatus string
err = tx.QueryRow(`SELECT status FROM checkins WHERE sub_id=? AND service_date=?`, subID, date).Scan(&oldStatus)
hasOld := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
delta := 0
if s.Abyss {
if s.CycleDays <= 0 {
return errors.New("该深渊套餐未设置周期天数")
}
d, _ := domain.ParseDate(date)
start := domain.ParseDateOr(s.StartDate, domain.Today())
idx := domain.CycleIndex(start, d, s.CycleDays)
if idx < 0 || idx >= s.TotalCount {
return errors.New("日期不在深渊周期内")
}
cycStart := domain.Date(domain.CycleStart(start, s.CycleDays, idx))
cycEnd := domain.Date(domain.CycleEnd(start, s.CycleDays, idx))
var otherDone int
if err := tx.QueryRow(`SELECT COUNT(*) FROM checkins WHERE sub_id=? AND status='done'
AND service_date BETWEEN ? AND ? AND service_date<>?`, subID, cycStart, cycEnd, date).Scan(&otherDone); err != nil {
return err
}
oldDone := hasOld && oldStatus == "done"
newDone := status == "done"
switch {
case newDone && !oldDone && otherDone == 0:
if s.UsedCount >= s.TotalCount {
return errors.New("所有周期已完成")
}
delta = 1
case !newDone && oldDone && otherDone == 0:
delta = -1
}
} else if s.Unit == domain.UnitCount {
oldDone := hasOld && oldStatus == "done"
newDone := status == "done"
switch {
case newDone && !oldDone:
if s.UsedCount+1 > s.TotalCount {
return errors.New("剩余次数不足,请先追加次数")
}
delta = 1
case !newDone && oldDone:
delta = -1
}
}
now := nowStamp() now := nowStamp()
if hasOld { res, err := tx.Exec(`UPDATE checkins SET status=?, note=?, operator_id=?, updated_at=? WHERE sub_id=? AND service_date=?`,
if _, err := tx.Exec(`UPDATE checkins SET status=?, note=?, operator_id=?, updated_at=? WHERE sub_id=? AND service_date=?`, status, note, nullIfZero(operatorID), now, subID, date)
status, note, nullIfZero(operatorID), now, subID, date); err != nil { if err != nil {
return err return err
} }
} else { if n, _ := res.RowsAffected(); n > 0 {
if _, err := tx.Exec(`INSERT INTO checkins(sub_id,service_date,status,note,operator_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, subID, date, status, note, nullIfZero(operatorID), now, now); err != nil {
return err
}
}
if delta != 0 {
newUsed := s.UsedCount + delta
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`, newUsed, now, subID); err != nil {
return err
}
}
return nil return nil
}
_, err = tx.Exec(`INSERT INTO checkins(sub_id,service_date,status,note,operator_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, subID, date, status, note, nullIfZero(operatorID), now, now)
return err
}) })
} }
// DeleteCheckin 撤销打卡(按次套餐返还次数),返回所属订阅 ID。 // DeleteCheckin 撤销打卡,返回所属订阅 ID。
func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) { func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
var subID int64 var subID int64
var status string
var date string
err := db.withTx(func(tx *sql.Tx) error { err := db.withTx(func(tx *sql.Tx) error {
if err := tx.QueryRow(`SELECT sub_id,status,service_date FROM checkins WHERE id=?`, id). if err := tx.QueryRow(`SELECT sub_id FROM checkins WHERE id=?`, id).Scan(&subID); err != nil {
Scan(&subID, &status, &date); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound return ErrNotFound
} }
return err return err
} }
if _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id); err != nil { _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id)
return err return err
}
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
delta := 0
if s.Unit == domain.UnitCount && status == "done" {
delta = -1
}
if s.Abyss && status == "done" && s.CycleDays > 0 {
d, _ := domain.ParseDate(date)
start := domain.ParseDateOr(s.StartDate, domain.Today())
idx := domain.CycleIndex(start, d, s.CycleDays)
if idx >= 0 {
cycStart := domain.Date(domain.CycleStart(start, s.CycleDays, idx))
cycEnd := domain.Date(domain.CycleEnd(start, s.CycleDays, idx))
var remain int
if err := tx.QueryRow(`SELECT COUNT(*) FROM checkins WHERE sub_id=? AND status='done'
AND service_date BETWEEN ? AND ?`, subID, cycStart, cycEnd).Scan(&remain); err != nil {
return err
}
if remain == 0 {
delta = -1
}
}
}
if delta != 0 {
newUsed := s.UsedCount + delta
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`,
newUsed, nowStamp(), subID); err != nil {
return err
}
}
return nil
}) })
return subID, err return subID, err
} }
@@ -179,27 +79,6 @@ func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, er
return scanCheckinMap(rows) return scanCheckinMap(rows)
} }
// CheckinsForSubs 只取指定订阅在区间内的打卡,避免客户自助页把全部客户的打卡读进内存。
func (db *DB) CheckinsForSubs(ids []int64, from, to string) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
if len(ids) == 0 {
return out, nil
}
args := make([]any, 0, len(ids)+2)
for _, id := range ids {
args = append(args, id)
}
args = append(args, from, to)
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.sub_id IN (`+placeholders(len(ids))+`) AND c.service_date BETWEEN ? AND ?`, args...)
if err != nil {
return nil, err
}
return scanCheckinMap(rows)
}
func scanCheckinMap(rows *sql.Rows) (map[int64]map[string]Checkin, error) { func scanCheckinMap(rows *sql.Rows) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{} out := map[int64]map[string]Checkin{}
defer rows.Close() defer rows.Close()
+10 -60
View File
@@ -23,11 +23,6 @@ type Customer struct {
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
// 归属打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
// Archived 停用(软删除):默认隐藏,订阅保留但不上日历 // Archived 停用(软删除):默认隐藏,订阅保留但不上日历
Archived bool Archived bool
@@ -49,14 +44,13 @@ func (c *Customer) TagList() []string {
return out return out
} }
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at,IFNULL(assignee_id,0),IFNULL((SELECT u.display_name FROM users u WHERE u.id=customers.assignee_id),''),IFNULL((SELECT u.color FROM users u WHERE u.id=customers.assignee_id),''),IFNULL(archived,0)` const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at,IFNULL(archived,0)`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) { func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer var c Customer
var archived int var archived int
err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact, err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact,
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt, &c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt, &archived)
&c.AssigneeID, &c.AssigneeName, &c.AssigneeColor, &archived)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -70,10 +64,10 @@ func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
// CreateCustomer 新建客户(尾号+姓名唯一)。 // CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) { func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp() now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,assignee_id,created_at,updated_at) res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact, strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), now, now) c.Tags, c.Source, c.Note, now, now)
if err != nil { if err != nil {
if isUnique(err) { if isUnique(err) {
return 0, ErrDuplicate return 0, ErrDuplicate
@@ -85,10 +79,10 @@ func (db *DB) CreateCustomer(c *Customer) (int64, error) {
// UpdateCustomer 更新客户。 // UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error { func (db *DB) UpdateCustomer(c *Customer) error {
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,assignee_id=?,updated_at=? _, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
WHERE id=?`, WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact, strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), nowStamp(), c.ID) c.Tags, c.Source, c.Note, nowStamp(), c.ID)
if err != nil && isUnique(err) { if err != nil && isUnique(err) {
return ErrDuplicate return ErrDuplicate
} }
@@ -157,7 +151,7 @@ func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays
return out, total, nil return out, total, nil
} }
// summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。 // summarizeSubs 生成「包月日常 剩12天 · 深渊日常 今天到期」式的摘要与最紧急等级。
func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) { func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) {
if len(subs) == 0 { if len(subs) == 0 {
return "暂无订阅", domain.LevelNone return "暂无订阅", domain.LevelNone
@@ -172,25 +166,6 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
continue continue
} }
var text string var text string
if s.Season {
switch {
case s.SeasonDone != "":
text = s.PackageName + " 通行证已完成"
case e.State == domain.StateExpired:
text = s.PackageName + " 通行证已结束"
default:
text = fmt.Sprintf("%s 通行证 %d/%d", s.PackageName, s.BlockDone(), s.BlockTotal())
}
} else if s.Abyss {
switch {
case s.TotalCount > 0 && s.UsedCount >= s.TotalCount:
text = fmt.Sprintf("%s %d/%d 周期", s.PackageName, s.UsedCount, s.TotalCount)
case e.State == domain.StateExpired:
text = fmt.Sprintf("%s %d/%d 周期 已过期", s.PackageName, s.UsedCount, s.TotalCount)
default:
text = fmt.Sprintf("%s %d/%d 周期", s.PackageName, s.UsedCount, s.TotalCount)
}
} else if s.Unit == domain.UnitDay {
switch { switch {
case e.State == domain.StateExpired: case e.State == domain.StateExpired:
text = s.PackageName + " 已过期" text = s.PackageName + " 已过期"
@@ -199,16 +174,6 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
default: default:
text = fmt.Sprintf("%s 剩%d天", s.PackageName, e.RemainingDays) text = fmt.Sprintf("%s 剩%d天", s.PackageName, e.RemainingDays)
} }
} else {
switch {
case e.RemainingCount <= 0:
text = s.PackageName + " 已用尽"
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
default:
text = fmt.Sprintf("%s 剩%d次", s.PackageName, e.RemainingCount)
}
}
parts = append(parts, text) parts = append(parts, text)
if rank[e.Level] > rank[worst] { if rank[e.Level] > rank[worst] {
worst = e.Level worst = e.Level
@@ -223,12 +188,6 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
return strings.Join(parts, " · "), worst return strings.Join(parts, " · "), worst
} }
// FindCustomerByTailName 精确查找(用于查重提示)。
func (db *DB) FindCustomerByTailName(tail, name string) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE tail=? AND name=?`,
strings.TrimSpace(tail), strings.TrimSpace(name)))
}
// CustomerCount 客户总数。 // CustomerCount 客户总数。
func (db *DB) CustomerCount() (int, error) { func (db *DB) CustomerCount() (int, error) {
var n int var n int
@@ -236,22 +195,13 @@ func (db *DB) CustomerCount() (int, error) {
return n, err return n, err
} }
// CustomersByTailExact 按手机尾号精确匹配客户(客户自助页用,不退回模糊匹配)。
func (db *DB) CustomersByTailExact(tail string) ([]Customer, error) {
tail = strings.TrimSpace(tail)
if tail == "" {
return nil, nil
}
return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail = ? ORDER BY id`, tail)
}
// CustomersByTail 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。 // CustomersByTail 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。
func (db *DB) CustomersByTail(tail string) ([]Customer, error) { func (db *DB) CustomersByTail(tail string) ([]Customer, error) {
tail = strings.TrimSpace(tail) tail = strings.TrimSpace(tail)
if tail == "" { if tail == "" {
return nil, nil return nil, nil
} }
out, err := db.CustomersByTailExact(tail) out, err := db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail = ? ORDER BY id`, tail)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -278,7 +228,7 @@ func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) {
return out, rows.Err() return out, rows.Err()
} }
// SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历与大屏。 // SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历。
func (db *DB) SetCustomerArchived(id int64, archived bool) error { func (db *DB) SetCustomerArchived(id int64, archived bool) error {
v := 0 v := 0
if archived { if archived {
+61 -78
View File
@@ -102,13 +102,6 @@ func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
} }
func normalizeUnit(u string) string {
if u == domain.UnitCount {
return domain.UnitCount
}
return domain.UnitDay
}
// Migrate 幂等建表。 // Migrate 幂等建表。
func (db *DB) Migrate() error { func (db *DB) Migrate() error {
for _, stmt := range schemaStatements { for _, stmt := range schemaStatements {
@@ -121,91 +114,98 @@ func (db *DB) Migrate() error {
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil { "ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
return err return err
} }
// v1.3:管理员颜色 + 客户归属打手 // v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
if err := db.ensureColumn("users", "color", if err := db.ensureColumn("packages", "tag",
"ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT ''"); err != nil { "ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("customers", "assignee_id",
"ALTER TABLE customers ADD COLUMN assignee_id INTEGER REFERENCES users(id)"); err != nil {
return err return err
} }
if err := db.ensureColumn("customers", "archived", if err := db.ensureColumn("customers", "archived",
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil { "ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
return err return err
} }
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签) // lite:把老库里按次 / 通行证 / 深渊的数据统一折算成按日
if err := db.ensureColumn("packages", "tag", return db.normalizeLegacyData()
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil { }
return err
// normalizeLegacyData 把 main 分支遗留的三种套餐类型折算成按日。
//
// - 通行证、深渊订阅当年就是 unit='day' 且起止日期完整,清掉标记即可;
// - 按次订阅按「1 次 = 1 天」折算成天:有 total_count 且没有结束日的,
// 结束日 = 开始日 + total_count - 1;仍缺结束日的退回开始日。
//
// 老库缺少这些列时自动跳过,因此可以反复执行。
func (db *DB) normalizeLegacyData() error {
// 套餐:一律按日
pkgSets := []string{"unit='day'"}
for _, col := range []string{"season", "abyss", "cycle_days", "default_validity_days"} {
if db.hasColumn("packages", col) {
pkgSets = append(pkgSets, col+"=0")
} }
// v1.8:包赛季套餐 + 按次默认有效期
if err := db.ensureColumn("packages", "season",
"ALTER TABLE packages ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
} }
if err := db.ensureColumn("packages", "default_validity_days", for _, col := range []string{"season_start", "season_end"} {
"ALTER TABLE packages ADD COLUMN default_validity_days INTEGER NOT NULL DEFAULT 0"); err != nil { if db.hasColumn("packages", col) {
return err pkgSets = append(pkgSets, col+"=''")
} }
if err := db.ensureColumn("packages", "season_start",
"ALTER TABLE packages ADD COLUMN season_start TEXT NOT NULL DEFAULT ''"); err != nil {
return err
} }
if err := db.ensureColumn("packages", "season_end", if _, err := db.sql.Exec(`UPDATE packages SET ` + strings.Join(pkgSets, ",")); err != nil {
"ALTER TABLE packages ADD COLUMN season_end TEXT NOT NULL DEFAULT ''"); err != nil { return fmt.Errorf("迁移套餐类型失败: %w", err)
return err
} }
if err := db.ensureColumn("subscriptions", "season",
"ALTER TABLE subscriptions ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil { // 订阅:按次先折算天数,再统一成按日
return err if db.hasColumn("subscriptions", "total_count") {
if _, err := db.sql.Exec(`UPDATE subscriptions
SET end_date = date(start_date, '+' || (total_count - 1) || ' days')
WHERE unit='count' AND IFNULL(total_count,0) > 0 AND IFNULL(end_date,'')=''`); err != nil {
return fmt.Errorf("迁移按次订阅失败: %w", err)
} }
if err := db.ensureColumn("subscriptions", "season_done",
"ALTER TABLE subscriptions ADD COLUMN season_done TEXT NOT NULL DEFAULT ''"); err != nil {
return err
} }
// v1.11:深渊模式(N 天一个周期)与通行证改名的数据字段 if _, err := db.sql.Exec(`UPDATE subscriptions
if err := db.ensureColumn("packages", "abyss", SET unit='day', end_date=IFNULL(NULLIF(end_date,''), start_date)
"ALTER TABLE packages ADD COLUMN abyss INTEGER NOT NULL DEFAULT 0"); err != nil { WHERE unit<>'day' OR IFNULL(end_date,'')=''`); err != nil {
return err return fmt.Errorf("迁移订阅计费方式失败: %w", err)
} }
if err := db.ensureColumn("packages", "cycle_days", subSets := []string{}
"ALTER TABLE packages ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil { for _, col := range []string{"season", "abyss", "cycle_days"} {
return err if db.hasColumn("subscriptions", col) {
subSets = append(subSets, col+"=0")
} }
if err := db.ensureColumn("subscriptions", "abyss",
"ALTER TABLE subscriptions ADD COLUMN abyss INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
} }
if err := db.ensureColumn("subscriptions", "cycle_days", if db.hasColumn("subscriptions", "season_done") {
"ALTER TABLE subscriptions ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil { subSets = append(subSets, "season_done=''")
return err }
if len(subSets) > 0 {
if _, err := db.sql.Exec(`UPDATE subscriptions SET ` + strings.Join(subSets, ",")); err != nil {
return fmt.Errorf("迁移订阅标记失败: %w", err)
}
} }
return nil return nil
} }
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。 // hasColumn 判断表里是否有某一列。
func (db *DB) ensureColumn(table, column, ddl string) error { func (db *DB) hasColumn(table, column string) bool {
rows, err := db.sql.Query("PRAGMA table_info(" + table + ")") rows, err := db.sql.Query("PRAGMA table_info(" + table + ")")
if err != nil { if err != nil {
return fmt.Errorf("读取 %s 表结构失败: %w", table, err) return false
} }
found := false defer rows.Close()
for rows.Next() { for rows.Next() {
var cid int var cid int
var name, ctype string var name, ctype string
var notnull, pk int var notnull, pk int
var dflt any var dflt any
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil { if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close() return false
return err
} }
if strings.EqualFold(name, column) { if strings.EqualFold(name, column) {
found = true return true
} }
} }
rows.Close() return false
if found { }
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
func (db *DB) ensureColumn(table, column, ddl string) error {
if db.hasColumn(table, column) {
return nil return nil
} }
if _, err := db.sql.Exec(ddl); err != nil { if _, err := db.sql.Exec(ddl); err != nil {
@@ -221,7 +221,6 @@ var schemaStatements = []string{
display_name TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT last_login_at TEXT
)`, )`,
@@ -235,7 +234,7 @@ var schemaStatements = []string{
`CREATE TABLE IF NOT EXISTS packages ( `CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')), unit TEXT NOT NULL DEFAULT 'day' CHECK (unit IN ('day')),
default_amount INTEGER NOT NULL DEFAULT 30, default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER, price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#3B6EA5', color TEXT NOT NULL DEFAULT '#3B6EA5',
@@ -244,12 +243,6 @@ var schemaStatements = []string{
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
calendar_separate INTEGER NOT NULL DEFAULT 0, calendar_separate INTEGER NOT NULL DEFAULT 0,
tag TEXT NOT NULL DEFAULT '', tag TEXT NOT NULL DEFAULT '',
season INTEGER NOT NULL DEFAULT 0,
default_validity_days INTEGER NOT NULL DEFAULT 0,
season_start TEXT NOT NULL DEFAULT '',
season_end TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0,
cycle_days INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
)`, )`,
@@ -263,7 +256,6 @@ var schemaStatements = []string{
tags TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
assignee_id INTEGER REFERENCES users(id),
archived INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@@ -273,16 +265,10 @@ var schemaStatements = []string{
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
package_id INTEGER NOT NULL REFERENCES packages(id), package_id INTEGER NOT NULL REFERENCES packages(id),
unit TEXT NOT NULL CHECK (unit IN ('day','count')), unit TEXT NOT NULL DEFAULT 'day' CHECK (unit IN ('day')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
start_date TEXT NOT NULL, start_date TEXT NOT NULL,
end_date TEXT, end_date TEXT,
total_count INTEGER NOT NULL DEFAULT 0,
used_count INTEGER NOT NULL DEFAULT 0,
season INTEGER NOT NULL DEFAULT 0,
season_done TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0,
cycle_days INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id), created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -304,9 +290,6 @@ var schemaStatements = []string{
old_end_date TEXT, old_end_date TEXT,
new_end_date TEXT, new_end_date TEXT,
days_added INTEGER NOT NULL DEFAULT 0, days_added INTEGER NOT NULL DEFAULT 0,
count_added INTEGER NOT NULL DEFAULT 0,
old_total INTEGER NOT NULL DEFAULT 0,
new_total INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
operator_id INTEGER REFERENCES users(id), operator_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL created_at TEXT NOT NULL
+5 -11
View File
@@ -38,25 +38,19 @@ func (db *DB) ExportSubscriptions(today time.Time, warnDays int) (*ExportRowSet,
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日/有效期", out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日",
"总次数", "已用", "剩余", "原文状态", "备注", "更新时间"}} "剩余", "原文状态", "备注", "更新时间"}}
stateLabel := map[string]string{ stateLabel := map[string]string{
domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中", domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中",
domain.StateExpired: "已到期", domain.StateUsedUp: "次数用尽", domain.StateClosed: "已结束", domain.StateExpired: "已到期", domain.StateClosed: "已结束",
} }
for _, s := range subs { for _, s := range subs {
e := s.Eval(today, warnDays) e := s.Eval(today, warnDays)
remaining := ""
if s.Unit == domain.UnitDay {
remaining = fmt.Sprintf("%d 天", e.RemainingDays)
} else {
remaining = fmt.Sprintf("%d 次", e.RemainingCount)
}
out.Rows = append(out.Rows, []string{ out.Rows = append(out.Rows, []string{
fmt.Sprint(s.ID), s.CustomerTail, s.CustomerName, s.PackageName, fmt.Sprint(s.ID), s.CustomerTail, s.CustomerName, s.PackageName,
map[string]string{domain.UnitDay: "按日", domain.UnitCount: "按次"}[s.Unit], "按日",
stateLabel[e.State], s.StartDate, s.EndDate, stateLabel[e.State], s.StartDate, s.EndDate,
fmt.Sprint(s.TotalCount), fmt.Sprint(s.UsedCount), remaining, s.Status, s.Note, s.UpdatedAt, fmt.Sprintf("%d 天", e.RemainingDays), s.Status, s.Note, s.UpdatedAt,
}) })
} }
return out, nil return out, nil
+32 -87
View File
@@ -5,85 +5,50 @@ import (
"errors" "errors"
"strconv" "strconv"
"strings" "strings"
"daili/internal/domain"
) )
// 套餐计费方式:lite 版只有「按日」一种,
// 但 packages.unit 列保留(写入固定值 "day"),以便老库平滑升级。
const unitDay = "day"
// Package 服务套餐。 // Package 服务套餐。
type Package struct { type Package struct {
ID int64 ID int64
Name string Name string
Unit string DefaultAmount int // 默认天数
DefaultAmount int
PriceCents int PriceCents int
HasPrice bool HasPrice bool
Color string Color string
Note string Note string
Archived bool Archived bool
SortOrder int SortOrder int
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页 // CalendarSeparate 勾选后在工作台日历中单独成页
CalendarSeparate bool CalendarSeparate bool
// Tag 套餐标签:相同标签的套餐在日历里合并显示 // Tag 套餐标签:相同标签的套餐在日历里合并显示
Tag string Tag string
// Season 通行证模式:开始~结束之间任意一天可整段完成
Season bool
// Abyss 深渊模式:CycleDays 天一个周期,共 DefaultAmount 个周期
Abyss bool
CycleDays int
// DefaultValidityDays 按次套餐默认有效期(天),0 不限
DefaultValidityDays int
// 通行证的固定区间(套餐级)
SeasonStart string
SeasonEnd string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
SubCount int SubCount int
ActiveCount int ActiveCount int
} }
// UnitLabel 返回类型中文名。 // UnitLabel 返回类型中文名(lite 版只有按日)。
func (p *Package) UnitLabel() string { func (p *Package) UnitLabel() string { return "按日" }
if p.Abyss {
return "深渊"
}
if p.Season {
return "通行证"
}
if p.Unit == domain.UnitCount {
return "按次"
}
return "按日"
}
// AmountText 返回「默认量」列的中文描述。 // AmountText 返回「默认量」列的中文描述。
func (p *Package) AmountText() string { func (p *Package) AmountText() string {
if p.Abyss {
return strconv.Itoa(p.DefaultAmount) + " 周期"
}
if p.Season {
return "整段"
}
if p.Unit == domain.UnitCount {
return strconv.Itoa(p.DefaultAmount) + " 次"
}
return strconv.Itoa(p.DefaultAmount) + " 天" return strconv.Itoa(p.DefaultAmount) + " 天"
} }
// CycleLabel 返回深渊模式的人话描述。 const packageCols = `id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at`
func (p *Package) CycleLabel() string {
if !p.Abyss || p.CycleDays <= 0 {
return ""
}
return strconv.Itoa(p.CycleDays) + " 天/周期"
}
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
var p Package var p Package
var unit string
var price sql.NullInt64 var price sql.NullInt64
var archived, separate, season, validity, abyss, cycleDays int var archived, separate int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, err := row.Scan(&p.ID, &p.Name, &unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt)
&abyss, &cycleDays, &p.CreatedAt, &p.UpdatedAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -92,10 +57,6 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
} }
p.Archived = archived == 1 p.Archived = archived == 1
p.CalendarSeparate = separate == 1 p.CalendarSeparate = separate == 1
p.Season = season == 1
p.Abyss = abyss == 1
p.CycleDays = cycleDays
p.DefaultValidityDays = validity
if price.Valid { if price.Valid {
p.PriceCents = int(price.Int64) p.PriceCents = int(price.Int64)
p.HasPrice = true p.HasPrice = true
@@ -105,7 +66,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
// ListPackages 列出套餐(含订阅计数)。 // ListPackages 列出套餐(含订阅计数)。
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,p.season,p.default_validity_days,p.season_start,p.season_end,p.abyss,p.cycle_days,p.created_at,p.updated_at, q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,p.created_at,p.updated_at,
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id), (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed') (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
FROM packages p` FROM packages p`
@@ -121,19 +82,15 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
out := []Package{} out := []Package{}
for rows.Next() { for rows.Next() {
var p Package var p Package
var unit string
var price sql.NullInt64 var price sql.NullInt64
var archived, separate, season, validity, abyss, cycleDays int var archived, separate int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, if err := rows.Scan(&p.ID, &p.Name, &unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
&abyss, &cycleDays, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err return nil, err
} }
p.Archived = archived == 1 p.Archived = archived == 1
p.CalendarSeparate = separate == 1 p.CalendarSeparate = separate == 1
p.Season = season == 1
p.Abyss = abyss == 1
p.CycleDays = cycleDays
p.DefaultValidityDays = validity
if price.Valid { if price.Valid {
p.PriceCents = int(price.Int64) p.PriceCents = int(price.Int64)
p.HasPrice = true p.HasPrice = true
@@ -145,27 +102,29 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
// Package 取单个套餐。 // Package 取单个套餐。
func (db *DB) Package(id int64) (*Package, error) { func (db *DB) Package(id int64) (*Package, error) {
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,season,default_validity_days,season_start,season_end,abyss,cycle_days,created_at,updated_at return scanPackage(db.sql.QueryRow(`SELECT `+packageCols+` FROM packages WHERE id=?`, id))
FROM packages WHERE id=?`, id))
} }
// CreatePackage 新建套餐。 func (p *Package) normalize() {
func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp()
p.Unit = normalizeUnit(p.Unit)
if p.DefaultAmount <= 0 { if p.DefaultAmount <= 0 {
p.DefaultAmount = 30 p.DefaultAmount = 30
} }
if strings.TrimSpace(p.Color) == "" { if strings.TrimSpace(p.Color) == "" {
p.Color = "#3B6EA5" p.Color = "#3B6EA5"
} }
}
// CreatePackage 新建套餐。
func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp()
p.normalize()
var price any var price any
if p.HasPrice { if p.HasPrice {
price = p.PriceCents price = p.PriceCents
} }
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,season,default_validity_days,season_start,season_end,abyss,cycle_days,created_at,updated_at) res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, b2i(p.Abyss), p.CycleDays, now, now) p.Name, unitDay, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), now, now)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -174,19 +133,13 @@ func (db *DB) CreatePackage(p *Package) (int64, error) {
// UpdatePackage 更新套餐。 // UpdatePackage 更新套餐。
func (db *DB) UpdatePackage(p *Package) error { func (db *DB) UpdatePackage(p *Package) error {
p.Unit = normalizeUnit(p.Unit) p.normalize()
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#3B6EA5"
}
var price any var price any
if p.HasPrice { if p.HasPrice {
price = p.PriceCents price = p.PriceCents
} }
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,season=?,default_validity_days=?,season_start=?,season_end=?,abyss=?,cycle_days=?,updated_at=? _, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, b2i(p.Abyss), p.CycleDays, nowStamp(), p.ID) WHERE id=?`, p.Name, unitDay, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), nowStamp(), p.ID)
return err return err
} }
@@ -243,11 +196,3 @@ func (db *DB) PackageTags() ([]string, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
// SeasonExpired 判断包赛季套餐在 today 是否已结束。
func (p Package) SeasonExpired(today string) bool {
if !p.Season || p.SeasonEnd == "" {
return false
}
return today > p.SeasonEnd
}
-93
View File
@@ -6,21 +6,6 @@ import (
"daili/internal/domain" "daili/internal/domain"
) )
// Stats 大屏统计数据。
type Stats struct {
Customers int
ActiveSubs int
PausedSubs int
DueToday int
Due7 int
DueTodaySubs []Subscription
Heat map[string]int // 结束日 -> 该日到期数量
MonthDue int
Top []Subscription
TodayTasks int
TodayDone int
}
// Badges 顶栏常驻徽标。 // Badges 顶栏常驻徽标。
func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) { func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) {
t := domain.Date(today) t := domain.Date(today)
@@ -37,84 +22,6 @@ func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) {
return return
} }
// ScreenStats 汇总大屏所需数据。
func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
st := &Stats{Heat: map[string]int{}}
var err error
if st.Customers, err = db.CustomerCount(); err != nil {
return nil, err
}
if st.ActiveSubs, err = db.CountActiveSubs(domain.Date(today)); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status='paused'
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`).Scan(&st.PausedSubs); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`,
domain.Date(today)).Scan(&st.DueToday); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`,
domain.Date(today), domain.Date(domain.AddDays(today, 6))).Scan(&st.Due7); err != nil {
return nil, err
}
if st.DueTodaySubs, err = db.DueOn(domain.Date(today)); err != nil {
return nil, err
}
// 本月到期热力
ms := domain.MonthStart(today.Year(), today.Month())
me := domain.MonthEnd(today.Year(), today.Month())
rows, err := db.sql.Query(`SELECT end_date, COUNT(*) FROM subscriptions s
JOIN customers c ON c.id=s.customer_id
WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date BETWEEN ? AND ? GROUP BY s.end_date`,
domain.Date(ms), domain.Date(me))
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
st.Heat[d] = n
st.MonthDue += n
}
rows.Close()
// TOP:按日按剩余天数、按次按剩余次数,各自排序后交替取前 10
t := domain.Date(today)
daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.unit='day' AND s.end_date>=?
ORDER BY s.end_date ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.unit='count' AND s.used_count < s.total_count
AND (IFNULL(s.end_date,'')='' OR s.end_date>=?)
ORDER BY (s.total_count - s.used_count) ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
for i := 0; i < 10 && len(st.Top) < 10; i++ {
if i < len(daySubs) {
st.Top = append(st.Top, daySubs[i])
}
if len(st.Top) < 10 && i < len(cntSubs) {
st.Top = append(st.Top, cntSubs[i])
}
if i >= len(daySubs) && i >= len(cntSubs) {
break
}
}
_ = warnDays
return st, nil
}
// RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。 // RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。
func (db *DB) RecentDue(from string, days int) ([]Subscription, error) { func (db *DB) RecentDue(from string, days int) ([]Subscription, error) {
to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1)) to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1))
+196 -287
View File
@@ -2,10 +2,8 @@ package store
import ( import (
"database/sql" "database/sql"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -26,9 +24,9 @@ func newTestDB(t *testing.T) *DB {
return db return db
} }
func mustPackage(t *testing.T, db *DB, name, unit string, amount int) *Package { func mustPackage(t *testing.T, db *DB, name string, amount int) *Package {
t.Helper() t.Helper()
id, err := db.CreatePackage(&Package{Name: name, Unit: unit, DefaultAmount: amount, Color: "#2F5D50"}) id, err := db.CreatePackage(&Package{Name: name, DefaultAmount: amount, Color: "#2F5D50"})
if err != nil { if err != nil {
t.Fatalf("创建套餐失败: %v", err) t.Fatalf("创建套餐失败: %v", err)
} }
@@ -50,7 +48,7 @@ func mustCustomer(t *testing.T, db *DB, tail, name string) int64 {
func TestCreateSubAndDuplicate(t *testing.T) { func TestCreateSubAndDuplicate(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -63,8 +61,8 @@ func TestCreateSubAndDuplicate(t *testing.T) {
if sub.EndDate != domain.Date(domain.AddDays(today, 29)) { if sub.EndDate != domain.Date(domain.AddDays(today, 29)) {
t.Fatalf("结束日=%s", sub.EndDate) t.Fatalf("结束日=%s", sub.EndDate)
} }
if sub.Unit != domain.UnitDay || sub.Status != domain.StatusActive { if sub.Status != domain.StatusActive {
t.Fatalf("unit=%s status=%s", sub.Unit, sub.Status) t.Fatalf("status=%s", sub.Status)
} }
if _, err := db.CreateSub(CreateSubInput{ if _, err := db.CreateSub(CreateSubInput{
@@ -85,7 +83,7 @@ func TestCreateSubAndDuplicate(t *testing.T) {
func TestExtendDaysFromTodayWhenExpired(t *testing.T) { func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -129,7 +127,7 @@ func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
func TestExtendDaysFromOldEndWhenActive(t *testing.T) { func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -151,7 +149,7 @@ func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
func TestExtendDaysRejectsBackwards(t *testing.T) { func TestExtendDaysRejectsBackwards(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -163,84 +161,9 @@ func TestExtendDaysRejectsBackwards(t *testing.T) {
} }
} }
func TestAddCountAndCheckinConsumption(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
cid := mustCustomer(t, db, "5678", "大鹏")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), TotalCount: 10,
})
if err != nil {
t.Fatal(err)
}
if sub.TotalCount != 10 || sub.UsedCount != 0 {
t.Fatalf("total=%d used=%d", sub.TotalCount, sub.UsedCount)
}
// 打卡完成 → 扣 1 次
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 同一天改成「跳过」→ 返还
if err := db.SaveCheckin(sub.ID, domain.Date(today), "skipped", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 再改回完成 → 再扣
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 撤销打卡 → 返还
ci, err := db.CheckinFor(sub.ID, domain.Date(today))
if err != nil {
t.Fatal(err)
}
if _, err := db.DeleteCheckin(ci.ID, 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 次数用尽后不能再打卡
_, _ = db.sql.Exec(`UPDATE subscriptions SET used_count=total_count WHERE id=?`, sub.ID)
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err == nil {
t.Fatal("次数用尽时打卡应被拒绝")
}
// 追加次数
if err := db.AddCount(sub.ID, 5, "", "加购", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.TotalCount != 15 {
t.Fatalf("total=%d want 15", got.TotalCount)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err != nil {
t.Fatalf("追加后应可打卡: %v", err)
}
}
func TestPauseResumeShiftsEndDate(t *testing.T) { func TestPauseResumeShiftsEndDate(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -276,35 +199,9 @@ func TestPauseResumeShiftsEndDate(t *testing.T) {
} }
} }
func TestCalendarSubsExcludesOpenEndedCount(t *testing.T) {
db := newTestDB(t)
dayPkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cntPkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
today := domain.Today()
cid := mustCustomer(t, db, "1234", "老张")
daySub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: dayPkg.ID, StartDate: domain.Date(today), Days: 30,
})
cid2 := mustCustomer(t, db, "5678", "大鹏")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: cntPkg.ID, StartDate: domain.Date(today), TotalCount: 10,
}); err != nil {
t.Fatal(err)
}
subs, err := db.CalendarSubs(domain.Date(today), domain.Date(today))
if err != nil {
t.Fatal(err)
}
if len(subs) != 1 || subs[0].ID != daySub.ID {
t.Fatalf("无有效期的按次订阅不应出现在日历: %+v", subs)
}
}
func TestBackup(t *testing.T) { func TestBackup(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30) mustPackage(t, db, "包月日常", 30)
path, err := db.BackupNow(14) path, err := db.BackupNow(14)
if err != nil { if err != nil {
t.Fatalf("备份失败: %v", err) t.Fatalf("备份失败: %v", err)
@@ -337,7 +234,7 @@ func TestSettingsAndBadges(t *testing.T) {
t.Fatalf("默认值=%s", got) t.Fatalf("默认值=%s", got)
} }
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
_, _ = db.CreateSub(CreateSubInput{ _, _ = db.CreateSub(CreateSubInput{
@@ -384,33 +281,9 @@ func TestUniqueCustomer(t *testing.T) {
var _ = time.Now var _ = time.Now
func TestScreenTopLimitedToTen(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
today := domain.Today()
for i := 0; i < 12; i++ {
cid, err := db.CreateCustomer(&Customer{Tail: "T" + strconv.Itoa(i), Name: "客户" + strconv.Itoa(i)})
if err != nil {
t.Fatalf("建客户失败: %v", err)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30 - i,
}); err != nil {
t.Fatalf("建订阅失败: %v", err)
}
}
st, err := db.ScreenStats(today, 3)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if len(st.Top) != 10 {
t.Fatalf("TOP 榜应为 10 条,实际 %d 条", len(st.Top))
}
}
func TestBackupKeepPrune(t *testing.T) { func TestBackupKeepPrune(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30) mustPackage(t, db, "包月日常", 30)
dir := db.BackupDir() dir := db.BackupDir()
names := []string{ names := []string{
"daili-20260101-000001.db", "daili-20260101-000001.db",
@@ -478,7 +351,7 @@ func TestSingleFileDatabase(t *testing.T) {
t.Fatalf("journal_mode=%s err=%v", mode, err) t.Fatalf("journal_mode=%s err=%v", mode, err)
} }
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
if _, err := db.CreateSub(CreateSubInput{ if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30, CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
@@ -513,7 +386,7 @@ func TestCopyDatabaseIsLossless(t *testing.T) {
if err := db.Migrate(); err != nil { if err := db.Migrate(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
sub, err := db.CreateSub(CreateSubInput{ sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30, CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
@@ -651,7 +524,7 @@ func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
func TestPackageCalendarSeparate(t *testing.T) { func TestPackageCalendarSeparate(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "每日任务", domain.UnitDay, 30) pkg := mustPackage(t, db, "每日任务", 30)
if pkg.CalendarSeparate { if pkg.CalendarSeparate {
t.Fatal("新建套餐默认不应勾选独立日历") t.Fatal("新建套餐默认不应勾选独立日历")
} }
@@ -678,7 +551,7 @@ func TestPackageCalendarSeparate(t *testing.T) {
func TestTaskUrgentFlags(t *testing.T) { func TestTaskUrgentFlags(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "4321", "老王") cid := mustCustomer(t, db, "4321", "老王")
sub1, err := db.CreateSub(CreateSubInput{ sub1, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30, CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
@@ -686,10 +559,9 @@ func TestTaskUrgentFlags(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pkg2 := mustPackage(t, db, "按次", domain.UnitCount, 10) pkg2 := mustPackage(t, db, "包周", 7)
sub2, err := db.CreateSub(CreateSubInput{ sub2, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()), CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()), Days: 7,
TotalCount: 10, EndDate: domain.Date(domain.AddDays(domain.Today(), 30)),
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -825,26 +697,17 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
if got, _ := db.Package(1); !got.CalendarSeparate { if got, _ := db.Package(1); !got.CalendarSeparate {
t.Fatal("迁移后新列不可写") t.Fatal("迁移后新列不可写")
} }
// 老库的 users / customers 也补上了新列 // 老客户仍可读
var userColor string if _, err := db.Customer(1); err != nil {
if err := db.sql.QueryRow(`SELECT IFNULL(color,'') FROM users WHERE id=1`).Scan(&userColor); err != nil {
t.Fatalf("users.color 未补上: %v", err)
}
if err := db.SetUserColor(1, "#123456"); err != nil {
t.Fatalf("老库 users.color 不可写: %v", err)
}
c, err := db.Customer(1)
if err != nil {
t.Fatalf("老客户读取失败: %v", err) t.Fatalf("老客户读取失败: %v", err)
} }
if c.AssigneeID != 0 { // 老套餐已被规范化成按日
t.Fatalf("老客户不应有打手: %+v", c) if pkg.UnitLabel() != "按日" {
t.Fatalf("老套餐未规范化为按日: %s", pkg.UnitLabel())
} }
if err := db.SetCustomerAssignee(1, 1); err != nil { // 迁移后仍可新建套餐(老库 packages.unit 是 NOT NULL 且没有默认值)
t.Fatalf("老库 customers.assignee_id 不可写: %v", err) if _, err := db.CreatePackage(&Package{Name: "新套餐", DefaultAmount: 7, Color: "#123456"}); err != nil {
} t.Fatalf("迁移后无法新建套餐: %v", err)
if c2, _ := db.Customer(1); c2.AssigneeID != 1 {
t.Fatalf("指派未生效: %+v", c2)
} }
// 新表 task_flags 已建立 // 新表 task_flags 已建立
@@ -858,171 +721,89 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
} }
} }
func TestUserColorAndAssignee(t *testing.T) { // TestSingleUserAccount 覆盖 lite 版只保留一个账户后的账户与会话行为。
func TestSingleUserAccount(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
// 新建管理员自动分配调色板颜色 u, err := db.CreateUser("admin", "老板", "daili123")
u1, err := db.CreateUser("dashou1", "阿强", "daili123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
u2, err := db.CreateUser("dashou2", "小美", "daili123") if u.ID != 1 {
if err != nil { t.Fatalf("首个账户 id 应为 1,实际 %d", u.ID)
t.Fatal(err)
} }
if u1.Color == "" || u2.Color == "" || u1.Color == u2.Color { if n, _ := db.CountUsers(); n != 1 {
t.Fatalf("自动颜色不合理: %q %q", u1.Color, u2.Color) t.Fatalf("账户数=%d want 1", n)
}
// 用户名唯一:不会再出现第二个账户
if _, err := db.CreateUser("admin", "另一个", "daili123"); err == nil {
t.Fatal("重复用户名应被拒绝")
} }
// 改色(小写转大写) // 登录
if err := db.SetUserColor(u2.ID, "#c0392b"); err != nil { if _, err := db.Authenticate("admin", "daili123"); err != nil {
t.Fatal(err) t.Fatalf("正确密码应可登录: %v", err)
} }
got, err := db.UserByID(u2.ID) if _, err := db.Authenticate("admin", "wrong"); err == nil {
if err != nil || got.Color != "#C0392B" { t.Fatal("错误密码不应通过")
t.Fatalf("颜色未保存或未规范化: %+v err=%v", got, err)
}
// 非法颜色
if err := db.SetUserColor(u2.ID, "red"); err == nil {
t.Fatal("非法颜色应报错")
}
if err := db.SetUserColor(u2.ID, "#12345"); err == nil {
t.Fatal("长度不对应报错")
}
// 不存在的账户
if err := db.SetUserColor(9999, "#123456"); err == nil {
t.Fatal("不存在的账户应报错")
} }
// 客户指派 // 改密码后旧密码失效
cid := mustCustomer(t, db, "3001", "客户甲") if err := db.UpdatePassword(u.ID, "newpass123"); err != nil {
if err := db.SetCustomerAssignee(cid, u1.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
c, err := db.Customer(cid) if _, err := db.Authenticate("admin", "daili123"); err == nil {
if err != nil { t.Fatal("旧密码不应还能登录")
t.Fatal(err)
} }
if c.AssigneeID != u1.ID || c.AssigneeName != "阿强" || c.AssigneeColor != "#2F5D50" { if _, err := db.Authenticate("admin", "newpass123"); err != nil {
t.Fatalf("客户指派信息不对: %+v", c) t.Fatalf("新密码应可登录: %v", err)
}
// 取消指派
if err := db.SetCustomerAssignee(cid, 0); err != nil {
t.Fatal(err)
}
if c2, _ := db.Customer(cid); c2.AssigneeID != 0 || c2.AssigneeName != "" {
t.Fatalf("取消指派失败: %+v", c2)
}
// 指派给不存在的打手
if err := db.SetCustomerAssignee(cid, 9999); err == nil {
t.Fatal("不存在的打手应报错")
} }
// 客户创建时直接带指派 // 改显示名称
cid2, err := db.CreateCustomer(&Customer{Tail: "3002", Name: "客户乙", AssigneeID: u2.ID}) if err := db.UpdateDisplayName(u.ID, " 强哥 "); err != nil {
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
c3, _ := db.Customer(cid2) got, err := db.UserByID(u.ID)
if c3.AssigneeName != "小美" || c3.AssigneeColor != "#C0392B" { if err != nil || got.DisplayName != "强哥" {
t.Fatalf("新建客户的指派未生效: %+v", c3) t.Fatalf("显示名称未更新: %+v err=%v", got, err)
}
// 订阅联表能带出打手
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
gotSub, err := db.Sub(sub.ID)
if err != nil {
t.Fatal(err)
}
if gotSub.AssigneeName != "小美" || gotSub.AssigneeColor != "#C0392B" {
t.Fatalf("订阅未带出打手: %+v", gotSub)
}
// 只有启用中的账户可作为打手候选
if err := db.SetUserActive(u1.ID, false); err != nil {
t.Fatal(err)
}
users, err := db.ActiveUsers()
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.ID == u1.ID {
t.Fatal("停用账户不应出现在打手候选里")
}
} }
} }
func TestUpdateUserByAdmin(t *testing.T) { // TestSessionLifecycle 覆盖会话创建、过期清理与登出。
func TestSessionLifecycle(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
u, err := db.CreateUser("admin", "老板", "daili123")
admin, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
u, err := db.CreateUser("dashou", "阿强", "dashou123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 建一个会话,稍后用密码重置把它踢掉 token := "tok-test"
token := "tok-" + domain.Date(domain.Today())
if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil { if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := db.SessionUser(token); err != nil { if _, err := db.SessionUser(token); err != nil {
t.Fatalf("会话应有效: %v", err) t.Fatalf("会话应有效: %v", err)
} }
// 过期会话不可用,且会被删除
// 只改昵称:会话保留 if err := db.CreateSession("tok-old", u.ID, -time.Hour, "127.0.0.1"); err != nil {
if err := db.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, _ := db.UserByID(u.ID) if _, err := db.SessionUser("tok-old"); err == nil {
if got.DisplayName != "强哥" || got.Username != "dashou" { t.Fatal("过期会话不应有效")
t.Fatalf("昵称未更新: %+v", got)
} }
if _, err := db.SessionUser(token); err != nil { // 登出
t.Fatalf("未改密码不应踢下线: %v", err) if err := db.DeleteSession(token); err != nil {
}
// 改用户名 + 重置密码:会话失效,新密码可登录
if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := db.SessionUser(token); err == nil { if _, err := db.SessionUser(token); err == nil {
t.Fatal("改密后旧会话应失效") t.Fatal("登出后会话应失效")
}
if _, err := db.Authenticate("laoban", "newpass123"); err != nil {
t.Fatalf("新密码应可登录: %v", err)
}
if _, err := db.Authenticate("laoban", "dashou123"); err == nil {
t.Fatal("旧密码不应还能登录")
}
// 用户名冲突
if err := db.UpdateUserByAdmin(u.ID, "admin", "", ""); !errors.Is(err, ErrDuplicate) {
t.Fatalf("重名应返回 ErrDuplicate, got %v", err)
}
// 空用户名
if err := db.UpdateUserByAdmin(u.ID, " ", "", ""); err == nil {
t.Fatal("空用户名应报错")
}
// 超管自己也在表里,但接口层才限制“只能改别人”
if admin.ID != 1 {
t.Fatalf("首个账户 id 应为 1,实际 %d", admin.ID)
} }
db.PurgeSessions()
} }
func TestCustomerArchiveFilters(t *testing.T) { func TestCustomerArchiveFilters(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
// 一个正常客户 + 一个按次客户 // 一个正常客户 + 一个按次客户
cid := mustCustomer(t, db, "7001", "小张") cid := mustCustomer(t, db, "7001", "小张")
@@ -1107,10 +888,138 @@ func TestCustomerArchiveFilters(t *testing.T) {
} }
} }
// TestNormalizeLegacyTypeData 验证 main 分支遗留的「按次 / 通行证 / 深渊」
// 数据在 lite 版迁移后统一折算成按日。
func TestNormalizeLegacyTypeData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.db")
old, err := sql.Open("sqlite", strings.ReplaceAll(path, "\\", "/"))
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE packages (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
default_amount INTEGER NOT NULL DEFAULT 30, price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#3B6EA5', note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
calendar_separate INTEGER NOT NULL DEFAULT 0, tag TEXT NOT NULL DEFAULT '',
season INTEGER NOT NULL DEFAULT 0, default_validity_days INTEGER NOT NULL DEFAULT 0,
season_start TEXT NOT NULL DEFAULT '', season_end TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0, cycle_days INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT, tail TEXT NOT NULL, name TEXT NOT NULL,
game_name TEXT NOT NULL DEFAULT '', game_server TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (tail, name))`,
`CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, package_id INTEGER NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
start_date TEXT NOT NULL, end_date TEXT,
total_count INTEGER NOT NULL DEFAULT 0, used_count INTEGER NOT NULL DEFAULT 0,
season INTEGER NOT NULL DEFAULT 0, season_done TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0, cycle_days INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '', created_by INTEGER,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT,
UNIQUE (customer_id, package_id))`,
// 三个套餐:按次 / 通行证 / 深渊
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('十次副本','count',10,'#111111',0,'','',0,0,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('通行证','day',1,'#222222',1,'2026-01-01','2026-01-31',0,0,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('深渊','day',4,'#333333',0,'','',1,7,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1001','甲','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1002','乙','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1003','丙','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 按次:10 次、已用 4 次、没有结束日 → 应折算成 2026-01-01 起共 10 天
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,created_at,updated_at)
VALUES(1,1,'count','active','2026-01-01',NULL,10,4,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 通行证:区间完整,清标记即可
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,season,created_at,updated_at)
VALUES(2,2,'day','active','2026-01-01','2026-01-31',1,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 深渊:周期 7 天 × 4 周期
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,abyss,cycle_days,total_count,created_at,updated_at)
VALUES(3,3,'day','active','2026-02-01','2026-02-28',1,7,4,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
}
for _, s := range stmts {
if _, err := old.Exec(s); err != nil {
t.Fatalf("准备老库失败: %v\nSQL: %s", err, s)
}
}
if err := old.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开老库失败: %v", err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
// 套餐:全部按日,season / abyss / cycle_days 归零
pkgs, err := db.ListPackages(true)
if err != nil {
t.Fatal(err)
}
if len(pkgs) != 3 {
t.Fatalf("套餐数量=%d want 3", len(pkgs))
}
for _, p := range pkgs {
if p.UnitLabel() != "按日" {
t.Fatalf("套餐 %q 未折算成按日: %s", p.Name, p.UnitLabel())
}
}
var legacy int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM packages WHERE unit<>'day' OR season<>0 OR abyss<>0 OR cycle_days<>0`).Scan(&legacy); err != nil {
t.Fatal(err)
}
if legacy != 0 {
t.Fatalf("仍有 %d 个套餐残留旧类型字段", legacy)
}
// 按次订阅:折算成 10 天,剩余 10 天
sub1, err := db.Sub(1)
if err != nil {
t.Fatal(err)
}
if sub1.StartDate != "2026-01-01" || sub1.EndDate != "2026-01-10" {
t.Fatalf("按次订阅折算区间不对: %s → %s", sub1.StartDate, sub1.EndDate)
}
// 通行证订阅:区间保留
sub2, err := db.Sub(2)
if err != nil {
t.Fatal(err)
}
if sub2.StartDate != "2026-01-01" || sub2.EndDate != "2026-01-31" {
t.Fatalf("通行证订阅区间被改动: %s → %s", sub2.StartDate, sub2.EndDate)
}
// 深渊订阅:区间保留
sub3, err := db.Sub(3)
if err != nil {
t.Fatal(err)
}
if sub3.StartDate != "2026-02-01" || sub3.EndDate != "2026-02-28" {
t.Fatalf("深渊订阅区间被改动: %s → %s", sub3.StartDate, sub3.EndDate)
}
// 迁移可重复执行
if err := db.Migrate(); err != nil {
t.Fatalf("重复迁移失败: %v", err)
}
}
func TestPackageTag(t *testing.T) { func TestPackageTag(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
p1 := mustPackage(t, db, "包月活动托", domain.UnitDay, 30) p1 := mustPackage(t, db, "包月活动托", 30)
if p1.Tag != "" { if p1.Tag != "" {
t.Fatalf("默认标签应为空: %q", p1.Tag) t.Fatalf("默认标签应为空: %q", p1.Tag)
} }
@@ -1123,7 +1032,7 @@ func TestPackageTag(t *testing.T) {
t.Fatalf("标签未保存: %+v err=%v", got, err) t.Fatalf("标签未保存: %+v err=%v", got, err)
} }
p2 := &Package{Name: "深渊委托", Unit: domain.UnitCount, DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "} p2 := &Package{Name: "深渊委托", DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "}
if _, err := db.CreatePackage(p2); err != nil { if _, err := db.CreatePackage(p2); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+29 -212
View File
@@ -10,28 +10,19 @@ import (
"daili/internal/domain" "daili/internal/domain"
) )
// Subscription 服务订阅。 // Subscription 服务订阅(lite 版只有按日:一定有开始日与结束日)。
type Subscription struct { type Subscription struct {
ID int64 ID int64
CustomerID int64 CustomerID int64
PackageID int64 PackageID int64
Unit string
Status string Status string
StartDate string StartDate string
EndDate string EndDate string
TotalCount int
UsedCount int
Note string Note string
CreatedBy int64 CreatedBy int64
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
ClosedAt string ClosedAt string
// Season 通行证;SeasonDone 标记整段完成的日期(空=未完成)
Season bool
SeasonDone string
// Abyss 深渊模式:CycleDays 天一个周期,TotalCount 是周期总数
Abyss bool
CycleDays int
// 联表 // 联表
PackageName string PackageName string
@@ -39,47 +30,11 @@ type Subscription struct {
CustomerTail string CustomerTail string
CustomerName string CustomerName string
CustomerContact string CustomerContact string
// 客户归属的打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
} }
// Eval 计算展示状态。 // Eval 计算展示状态。
func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval { func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
return domain.EvalSub(s.Unit, s.Status, s.StartDate, s.EndDate, s.TotalCount, s.UsedCount, today, warnDays) return domain.EvalSub(s.Status, s.StartDate, s.EndDate, today, warnDays)
}
// IsCount 是否按次套餐。
func (s *Subscription) IsCount() bool { return s.Unit == domain.UnitCount }
// IsBlock 是否整段型(通行证 / 深渊):日历上按连续色块展示,不按天计任务。
func (s *Subscription) IsBlock() bool { return s.Season || s.Abyss }
// BlockTotal 返回整段型订阅的总单数:通行证固定 1,深渊为周期数。
func (s *Subscription) BlockTotal() int {
if s.Season {
return 1
}
if s.Abyss {
return s.TotalCount
}
return 0
}
// BlockDone 返回整段型订阅的已完成单数:通行证看完成标记,深渊看已完成周期数。
func (s *Subscription) BlockDone() int {
if s.Season {
if s.SeasonDone != "" {
return 1
}
return 0
}
if s.Abyss {
return s.UsedCount
}
return 0
} }
// Pause 暂停分段。 // Pause 暂停分段。
@@ -101,9 +56,6 @@ type Adjustment struct {
OldEndDate string OldEndDate string
NewEndDate string NewEndDate string
DaysAdded int DaysAdded int
CountAdded int
OldTotal int
NewTotal int
Note string Note string
OperatorID int64 OperatorID int64
OperatorName string OperatorName string
@@ -122,42 +74,30 @@ func (a *Adjustment) KindLabel() string {
return "开通" return "开通"
case "extend_days": case "extend_days":
return "追加天数" return "追加天数"
case "add_count":
return "追加次数"
case "set_end_date": case "set_end_date":
return "调整结束日" return "调整结束日"
} }
return "调整" return "调整"
} }
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''), const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.status,s.start_date,IFNULL(s.end_date,''),
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''), s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
IFNULL(s.season,0),IFNULL(s.season_done,''),IFNULL(s.abyss,0),IFNULL(s.cycle_days,0), p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
p.name,p.color,c.tail,c.name,IFNULL(c.contact,''),
IFNULL(c.assignee_id,0),
IFNULL((SELECT u.display_name FROM users u WHERE u.id=c.assignee_id),''),
IFNULL((SELECT u.color FROM users u WHERE u.id=c.assignee_id),'')
FROM subscriptions s FROM subscriptions s
JOIN packages p ON p.id=s.package_id JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_id` JOIN customers c ON c.id=s.customer_id`
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) { func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
var s Subscription var s Subscription
var seasonInt, abyssInt, cycleDays int err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Status, &s.StartDate, &s.EndDate,
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt, &s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact)
&seasonInt, &s.SeasonDone, &abyssInt, &cycleDays,
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact,
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return nil, err return nil, err
} }
s.Season = seasonInt == 1
s.Abyss = abyssInt == 1
s.CycleDays = cycleDays
return &s, nil return &s, nil
} }
@@ -218,7 +158,7 @@ type SubFilter struct {
Today time.Time Today time.Time
} }
const expiredExpr = `((s.unit='day' AND s.end_date < ?) OR (s.unit='count' AND (s.used_count>=s.total_count OR (IFNULL(s.end_date,'')<>'' AND s.end_date < ?))))` const expiredExpr = `(IFNULL(s.end_date,'')<>'' AND s.end_date < ?)`
// ListSubs 分页查询订阅。 // ListSubs 分页查询订阅。
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) { func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
@@ -237,14 +177,14 @@ func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
switch f.State { switch f.State {
case "active": case "active":
where = append(where, "s.status='active'", "NOT "+expiredExpr) where = append(where, "s.status='active'", "NOT "+expiredExpr)
args = append(args, today, today) args = append(args, today)
case "paused": case "paused":
where = append(where, "s.status='paused'") where = append(where, "s.status='paused'")
case "closed": case "closed":
where = append(where, "s.status='closed'") where = append(where, "s.status='closed'")
case "expired": case "expired":
where = append(where, "s.status<>'closed'", expiredExpr) where = append(where, "s.status<>'closed'", expiredExpr)
args = append(args, today, today) args = append(args, today)
case "due7": case "due7":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?") where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 6))) args = append(args, today, domain.Date(domain.AddDays(f.Today, 6)))
@@ -289,7 +229,6 @@ type CreateSubInput struct {
StartDate string StartDate string
EndDate string EndDate string
Days int Days int
TotalCount int
Note string Note string
OperatorID int64 OperatorID int64
} }
@@ -303,25 +242,19 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
if pkg.Archived { if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅") return nil, errors.New("该套餐已归档,不能新建订阅")
} }
if pkg.Season && strings.TrimSpace(in.StartDate) == "" {
in.StartDate = pkg.SeasonStart
}
start, err := domain.ParseDate(in.StartDate) start, err := domain.ParseDate(in.StartDate)
if err != nil { if err != nil {
return nil, errors.New("开始日期格式不正确") return nil, errors.New("开始日期格式不正确")
} }
unit := pkg.Unit
endDate := "" days := in.Days
days := 0
total := 0
if unit == domain.UnitDay {
days = in.Days
if days <= 0 { if days <= 0 {
days = pkg.DefaultAmount days = pkg.DefaultAmount
} }
if days <= 0 { if days <= 0 {
days = 30 days = 30
} }
endDate := ""
if in.EndDate != "" { if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate) end, err := domain.ParseDate(in.EndDate)
if err != nil { if err != nil {
@@ -335,59 +268,6 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
} else { } else {
endDate = domain.Date(domain.AddDays(start, days-1)) endDate = domain.Date(domain.AddDays(start, days-1))
} }
} else {
total = in.TotalCount
if total <= 0 {
total = pkg.DefaultAmount
}
if total <= 0 {
return nil, errors.New("总次数必须大于 0")
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("有效期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
} else if pkg.DefaultValidityDays > 0 {
endDate = domain.Date(domain.AddDays(start, pkg.DefaultValidityDays-1))
}
}
if pkg.Abyss {
if pkg.CycleDays <= 0 {
return nil, errors.New("该深渊套餐未设置周期天数")
}
total = in.TotalCount
if total <= 0 {
total = pkg.DefaultAmount
}
if total <= 0 {
total = 1
}
endDate = domain.Date(domain.AddDays(start, pkg.CycleDays*total-1))
unit = domain.UnitDay
days = pkg.CycleDays * total
} else if pkg.Season {
if pkg.SeasonStart == "" || pkg.SeasonEnd == "" {
return nil, errors.New("该通行证套餐未设置起止日期")
}
end, err := domain.ParseDate(pkg.SeasonEnd)
if err != nil {
return nil, errors.New("通行证结束日期不正确")
}
if domain.DayDiff(domain.Today(), end) < 0 {
return nil, errors.New("该通行证已结束,不能开通")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("通行证结束日期早于开始日期")
}
endDate = domain.Date(end)
unit = domain.UnitDay
days = domain.DayDiff(start, end) + 1
}
now := nowStamp() now := nowStamp()
var id int64 var id int64
@@ -401,9 +281,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
return err return err
} }
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at,season,abyss,cycle_days) res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,note,created_by,created_at,updated_at)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?,?,?)`, VALUES(?,?,?,'active',?,?,?,?,?,?)`,
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now, b2i(pkg.Season), b2i(pkg.Abyss), pkg.CycleDays) in.CustomerID, in.PackageID, unitDay, domain.Date(start), nullIfEmpty(endDate), in.Note, nullIfZero(in.OperatorID), now, now)
if err != nil { if err != nil {
if isUnique(err) { if isUnique(err) {
return errors.New("该客户已订阅此套餐,请改用「追加时长」") return errors.New("该客户已订阅此套餐,请改用「追加时长」")
@@ -411,9 +291,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
return err return err
} }
id, _ = res.LastInsertId() id, _ = res.LastInsertId()
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at) _, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?)`,
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, 0, 0, total, "新建订阅", nullIfZero(in.OperatorID), now) id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, "新建订阅", nullIfZero(in.OperatorID), now)
return err return err
}) })
if err != nil { if err != nil {
@@ -442,9 +322,6 @@ func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64)
if err != nil { if err != nil {
return err return err
} }
if s.Unit != domain.UnitDay {
return errors.New("该订阅按次计费,请使用「追加次数」")
}
if s.Status == domain.StatusClosed { if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」") return errors.New("订阅已结束,请先「重新启用」")
} }
@@ -461,58 +338,14 @@ func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64)
domain.Date(newEnd), status, nowStamp(), subID); err != nil { domain.Date(newEnd), status, nowStamp(), subID); err != nil {
return err return err
} }
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at) _, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?)`,
subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp()) subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp())
return err return err
}) })
return plan, err return plan, err
} }
// AddCount 按次追加次数(可同时调整有效期)。
func (db *DB) AddCount(subID int64, add int, newEndDate, note string, operatorID int64) error {
if add <= 0 {
return errors.New("追加次数必须大于 0")
}
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitCount {
return errors.New("该订阅按日计费,请使用「追加天数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
endDate := s.EndDate
if newEndDate != "" {
end, err := domain.ParseDate(newEndDate)
if err != nil {
return errors.New("有效期格式不正确")
}
if end.Before(domain.ParseDateOr(s.StartDate, domain.Today())) {
return errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
newTotal := s.TotalCount + add
if _, err := tx.Exec(`UPDATE subscriptions SET total_count=?, end_date=?, status=?, updated_at=? WHERE id=?`,
newTotal, nullIfEmpty(endDate), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,0,?,?,?,?,?,?)`,
subID, "add_count", s.EndDate, endDate, add, s.TotalCount, newTotal, note, nullIfZero(operatorID), nowStamp())
return err
})
return err
}
// PauseSub 暂停订阅。 // PauseSub 暂停订阅。
func (db *DB) PauseSub(subID, operatorID int64) error { func (db *DB) PauseSub(subID, operatorID int64) error {
return db.withTx(func(tx *sql.Tx) error { return db.withTx(func(tx *sql.Tx) error {
@@ -535,7 +368,7 @@ func (db *DB) PauseSub(subID, operatorID int64) error {
}) })
} }
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。 // ResumeSub 恢复订阅,顺延结束日,返回顺延天数。
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) { func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
var shift int var shift int
today := domain.Today() today := domain.Today()
@@ -563,7 +396,7 @@ func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil { if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
return err return err
} }
if s.Unit == domain.UnitDay && shift > 0 { if shift > 0 {
oldEnd := domain.ParseDateOr(s.EndDate, today) oldEnd := domain.ParseDateOr(s.EndDate, today)
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`, if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil { domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
@@ -593,7 +426,7 @@ func (db *DB) ReopenSub(subID int64) error {
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。 // AdjustmentsBySub 取某订阅的追加记录(新→旧)。
func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) { func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''), return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at, a.days_added,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name c.tail,c.name,p.name
FROM sub_adjustments a FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id LEFT JOIN users u ON u.id=a.operator_id
@@ -606,7 +439,7 @@ func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
// RecentAdjustments 最近追加记录。 // RecentAdjustments 最近追加记录。
func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) { func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''), return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at, a.days_added,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name c.tail,c.name,p.name
FROM sub_adjustments a FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id LEFT JOIN users u ON u.id=a.operator_id
@@ -626,7 +459,7 @@ func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
for rows.Next() { for rows.Next() {
var a Adjustment var a Adjustment
if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate, if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate,
&a.DaysAdded, &a.CountAdded, &a.OldTotal, &a.NewTotal, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt, &a.DaysAdded, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil { &a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
return nil, err return nil, err
} }
@@ -671,10 +504,8 @@ func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
func (db *DB) CountActiveSubs(today string) (int, error) { func (db *DB) CountActiveSubs(today string) (int, error) {
var n int var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active'
AND IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0 AND ( AND IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0
(s.unit='day' AND s.end_date >= ?) OR AND s.end_date >= ?`, today).Scan(&n)
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
)`, today, today).Scan(&n)
return n, err return n, err
} }
@@ -683,20 +514,6 @@ func (s *Subscription) SubTitle() string {
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName) return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
} }
// SeasonComplete 把通行证订阅整段标记为已完成。
func (db *DB) SeasonComplete(subID int64, date string) error {
sub, err := db.Sub(subID)
if err != nil {
return err
}
if !sub.Season {
return errors.New("不是通行证订阅")
}
_, err = db.sql.Exec(`UPDATE subscriptions SET season_done=?, updated_at=? WHERE id=?`,
date, nowStamp(), subID)
return err
}
// DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。 // DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。
func (db *DB) DeleteSubscription(subID int64) error { func (db *DB) DeleteSubscription(subID int64) error {
res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID) res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID)
+12 -160
View File
@@ -10,46 +10,25 @@ import (
) )
// User 管理员账户。 // User 管理员账户。
//
// lite 版只保留首次启动时创建的那一个账户:不再有新增/停用/改他人资料,
// 因此这里只保留登录、改自己密码与会话相关的最小集合。
type User struct { type User struct {
ID int64 ID int64
Username string Username string
DisplayName string DisplayName string
IsActive bool IsActive bool
Color string
CreatedAt string CreatedAt string
LastLoginAt string LastLoginAt string
} }
// AssigneePalette 是新建管理员时自动分配的颜色(区分度较高的深色系)。
var AssigneePalette = []string{
"#2F5D50", "#8C4A3C", "#3E5C8A", "#7A5AA6",
"#A66A1E", "#2C7A7B", "#8A3B5C", "#5B6B2F",
}
// NormalizeColor 校验并规范化 #RRGGBB;空值返回空。
func NormalizeColor(c string) (string, error) {
c = strings.TrimSpace(c)
if c == "" {
return "", nil
}
if len(c) != 7 || c[0] != '#' {
return "", errors.New("颜色格式应为 #RRGGBB")
}
for _, r := range c[1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return "", errors.New("颜色格式应为 #RRGGBB")
}
}
return strings.ToUpper(c), nil
}
func scanUser(row interface { func scanUser(row interface {
Scan(dest ...any) error Scan(dest ...any) error
}) (*User, error) { }) (*User, error) {
var u User var u User
var active int var active int
var last sql.NullString var last sql.NullString
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last, &u.Color); err != nil { if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -60,7 +39,7 @@ func scanUser(row interface {
return &u, nil return &u, nil
} }
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,''), IFNULL(color,'')` const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
// CountUsers 返回账户数量。 // CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) { func (db *DB) CountUsers() (int, error) {
@@ -69,16 +48,15 @@ func (db *DB) CountUsers() (int, error) {
return n, err return n, err
} }
// CreateUser 新建账户。 // CreateUser 新建账户。只用于首次初始化,之后不再允许新增。
func (db *DB) CreateUser(username, displayName, password string) (*User, error) { func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
hash, err := pw.Hash(password) hash, err := pw.Hash(password)
if err != nil { if err != nil {
return nil, err return nil, err
} }
now := nowStamp() now := nowStamp()
color := db.nextAssigneeColor() res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, color, created_at) VALUES(?,?,?,1,?)`, username, displayName, hash, now)
VALUES(?,?,?,1,?,?)`, username, displayName, hash, color, now)
if err != nil { if err != nil {
if isUnique(err) { if isUnique(err) {
return nil, ErrDuplicate return nil, ErrDuplicate
@@ -86,106 +64,7 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err return nil, err
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, Color: color, CreatedAt: now}, nil return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
}
// nextAssigneeColor 依据现有账户数循环取调色板,尽量不重复。
func (db *DB) nextAssigneeColor() string {
n, _ := db.CountUsers()
return AssigneePalette[n%len(AssigneePalette)]
}
// SetUserColor 设置管理员颜色(空字符串表示清除)。
func (db *DB) SetUserColor(userID int64, color string) error {
c, err := NormalizeColor(color)
if err != nil {
return err
}
res, err := db.sql.Exec(`UPDATE users SET color=? WHERE id=?`, c, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetCustomerAssignee 把客户指派给某个打手;userID=0 表示取消指派。
func (db *DB) SetCustomerAssignee(customerID, userID int64) error {
var v any
if userID > 0 {
if _, err := db.UserByID(userID); err != nil {
return errors.New("打手不存在")
}
v = userID
}
res, err := db.sql.Exec(`UPDATE customers SET assignee_id=?, updated_at=? WHERE id=?`, v, nowStamp(), customerID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ActiveUsers 返回在用的管理员(打手候选)。
func (db *DB) ActiveUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users WHERE is_active=1 ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// DeleteUserSessions 清除某个账户的全部登录会话。
func (db *DB) DeleteUserSessions(userID int64) error {
_, err := db.sql.Exec(`DELETE FROM sessions WHERE user_id=?`, userID)
return err
}
// UpdateUserByAdmin 由超管修改用户名/昵称,password 非空时同时重置密码。
// 重置密码会强制该账户下线。
func (db *DB) UpdateUserByAdmin(userID int64, username, displayName, password string) error {
username = strings.TrimSpace(username)
displayName = strings.TrimSpace(displayName)
if username == "" {
return errors.New("用户名不能为空")
}
hash := ""
if password != "" {
var err error
hash, err = pw.Hash(password)
if err != nil {
return err
}
}
if _, err := db.sql.Exec(`UPDATE users SET username=?, display_name=? WHERE id=?`,
username, displayName, userID); err != nil {
if isUnique(err) {
return ErrDuplicate
}
return err
}
if hash != "" {
if _, err := db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID); err != nil {
return err
}
if err := db.DeleteUserSessions(userID); err != nil {
return err
}
}
return nil
} }
// Authenticate 校验用户名口令。 // Authenticate 校验用户名口令。
@@ -213,29 +92,6 @@ func (db *DB) UserByID(id int64) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id)) return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
} }
// UserByUsername 按用户名取账户。
func (db *DB) UserByUsername(name string) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE username=?`, name))
}
// ListUsers 列出账户。
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// UpdatePassword 修改口令。 // UpdatePassword 修改口令。
func (db *DB) UpdatePassword(userID int64, password string) error { func (db *DB) UpdatePassword(userID int64, password string) error {
hash, err := pw.Hash(password) hash, err := pw.Hash(password)
@@ -246,13 +102,9 @@ func (db *DB) UpdatePassword(userID int64, password string) error {
return err return err
} }
// SetUserActive 启用/停用账户。 // UpdateDisplayName 修改显示名称(仅限自己)。
func (db *DB) SetUserActive(userID int64, active bool) error { func (db *DB) UpdateDisplayName(userID int64, displayName string) error {
v := 0 _, err := db.sql.Exec(`UPDATE users SET display_name=? WHERE id=?`, strings.TrimSpace(displayName), userID)
if active {
v = 1
}
_, err := db.sql.Exec(`UPDATE users SET is_active=? WHERE id=?`, v, userID)
return err return err
} }
+1 -206
View File
@@ -26,20 +26,10 @@ type DayCell struct {
DoneCount int DoneCount int
UrgentCnt int UrgentCnt int
Heat int Heat int
// 通行证 / 深渊色块
BlockColor string
BlockDone int
BlockTotal int
BlockText string
BlockTitle string
// BlockPercent 色块底色浓度:通行证固定 18%,深渊按周期依次减淡
BlockPercent int
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount) // PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
PendingCount int PendingCount int
Tickets []Ticket Tickets []Ticket
More int More int
// Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰)
Strip []string
} }
// Ticket 是日历格里的色签。 // Ticket 是日历格里的色签。
@@ -53,8 +43,6 @@ type Ticket struct {
Skipped bool Skipped bool
IsDue bool IsDue bool
Urgent bool Urgent bool
// AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底)
AssigneeColor string
} }
// TaskRow 是当日待打清单的一行。 // TaskRow 是当日待打清单的一行。
@@ -124,7 +112,6 @@ type calSub struct {
start time.Time start time.Time
end time.Time end time.Time
hasEnd bool hasEnd bool
eval domain.Eval
pauses []store.Pause pauses []store.Pause
checkins map[string]store.Checkin checkins map[string]store.Checkin
} }
@@ -141,93 +128,13 @@ func (c calSub) pausedOn(date string) bool {
return false return false
} }
// isBlock 是否为通行证 / 深渊这种整段型订阅。
func (c calSub) isBlock() bool { return c.sub.IsBlock() }
// blockCovers 只判断日期是否落在整段区间内(已完成也继续显示色块)。
func (c calSub) blockCovers(date string) bool {
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
return false
}
return date >= c.sub.StartDate && date <= c.sub.EndDate
}
// blockDone 返回整段型订阅的已完成数。
func (c calSub) blockDone() int { return c.sub.BlockDone() }
// blockTotal 返回整段型订阅的总数。
func (c calSub) blockTotal() int { return c.sub.BlockTotal() }
// cycleBounds 返回 date 所在深渊周期的起止日。
func (c calSub) cycleBounds(date string) (string, string, bool) {
if !c.sub.Abyss || c.sub.CycleDays <= 0 {
return "", "", false
}
d, err := domain.ParseDate(date)
if err != nil {
return "", "", false
}
idx := domain.CycleIndex(c.start, d, c.sub.CycleDays)
if idx < 0 || idx >= c.sub.TotalCount {
return "", "", false
}
return domain.Date(domain.CycleStart(c.start, c.sub.CycleDays, idx)),
domain.Date(domain.CycleEnd(c.start, c.sub.CycleDays, idx)), true
}
// cycleDone 判断 date 所在周期是否已经完成(周期内任意一天打卡即算)。
func (c calSub) cycleDone(date string) bool {
s, e, ok := c.cycleBounds(date)
if !ok {
return false
}
for d, ci := range c.checkins {
if d >= s && d <= e && ci.Status == "done" {
return true
}
}
return false
}
// blockNeedsWork 表示这一天是否需要出现在待完成清单里。
func (c calSub) blockNeedsWork(date string) bool {
if !c.blockCovers(date) || c.sub.Status == domain.StatusClosed {
return false
}
if c.sub.Season {
return c.sub.SeasonDone == ""
}
if c.sub.Abyss {
if c.sub.TotalCount <= 0 || c.sub.UsedCount >= c.sub.TotalCount {
return false
}
return !c.cycleDone(date)
}
return false
}
func (c calSub) covers(date string) bool { func (c calSub) covers(date string) bool {
if c.sub.Status == domain.StatusClosed || !c.hasEnd { if c.sub.Status == domain.StatusClosed || !c.hasEnd {
return false return false
} }
if c.sub.Season {
if c.sub.SeasonDone != "" {
return false
}
return date >= c.sub.StartDate && date <= c.sub.EndDate
}
if c.sub.Abyss {
if c.sub.TotalCount > 0 && c.sub.UsedCount >= c.sub.TotalCount {
return false
}
return date >= c.sub.StartDate && date <= c.sub.EndDate && !c.pausedOn(date)
}
if date < c.sub.StartDate || date > c.sub.EndDate { if date < c.sub.StartDate || date > c.sub.EndDate {
return false return false
} }
if c.sub.Unit == domain.UnitCount && c.eval.RemainingCount <= 0 {
return false
}
return !c.pausedOn(date) return !c.pausedOn(date)
} }
@@ -277,21 +184,6 @@ func buildCalendarTabs(pkgs []store.Package, subs []store.Subscription, total in
return tabs, true return tabs, true
} }
// wideFrom/wideTo 把打卡查询范围放大,保证跨月的深渊周期也能判定完成。
func wideFrom(s string) string {
if t, err := domain.ParseDate(s); err == nil {
return domain.Date(domain.AddDays(t, -400))
}
return s
}
func wideTo(s string) string {
if t, err := domain.ParseDate(s); err == nil {
return domain.Date(domain.AddDays(t, 400))
}
return s
}
// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 取该套餐,tag 非空取该标签下的所有套餐。 // loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 取该套餐,tag 非空取该标签下的所有套餐。
func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]store.Subscription, map[int64][]store.Pause, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) { func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]store.Subscription, map[int64][]store.Pause, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
subs, err := s.db.CalendarSubs(from, to) subs, err := s.db.CalendarSubs(from, to)
@@ -327,7 +219,7 @@ func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]s
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }
checkins, err := s.db.CheckinsBetween(wideFrom(from), wideTo(to)) checkins, err := s.db.CheckinsBetween(from, to)
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }
@@ -380,7 +272,6 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
cs := calSub{ cs := calSub{
sub: sub, sub: sub,
start: domain.ParseDateOr(sub.StartDate, today), start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID], pauses: pauses[sub.ID],
checkins: checkins[sub.ID], checkins: checkins[sub.ID],
} }
@@ -443,20 +334,6 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if !cell.InMonth { if !cell.InMonth {
continue continue
} }
if cs.isBlock() {
if cs.blockCovers(ds) && cell.BlockText == "" {
cell.BlockColor = cs.sub.PackageColor
cell.BlockText = blockCellText(cs, ds)
cell.BlockDone = 0
if cell.BlockText == "1/1" {
cell.BlockDone = 1
}
cell.BlockTotal = 1
cell.BlockTitle = cs.sub.PackageName + " · " + cell.BlockText
cell.BlockPercent = blockCellPercent(cs, day)
}
continue
}
if !cs.covers(ds) { if !cs.covers(ds) {
continue continue
} }
@@ -476,7 +353,6 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
Skipped: ci.Status == "skipped", Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds, IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent, Urgent: isUrgent,
AssigneeColor: cs.sub.AssigneeColor,
} }
if t.Done || t.Skipped { if t.Done || t.Skipped {
cell.DoneCount++ cell.DoneCount++
@@ -492,8 +368,6 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
} }
return tickets[i].SubID < tickets[j].SubID return tickets[i].SubID < tickets[j].SubID
}) })
// 竖条按“当天全部任务”算,不能被色签的 3 条上限截断
cell.Strip = assigneeStrip(tickets)
if len(tickets) > 3 { if len(tickets) > 3 {
cell.More = len(tickets) - 3 cell.More = len(tickets) - 3
tickets = tickets[:3] tickets = tickets[:3]
@@ -519,21 +393,6 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if cs.sub.EndDate == sel { if cs.sub.EndDate == sel {
due = append(due, newSubView(cs.sub, today, warn)) due = append(due, newSubView(cs.sub, today, warn))
} }
if cs.isBlock() {
if !cs.blockNeedsWork(sel) {
continue
}
row := TaskRow{SubView: newSubView(cs.sub, today, warn), Urgent: urgent[cs.sub.ID][sel]}
if ci, ok := checkins[cs.sub.ID][sel]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
tasks = append(tasks, row)
continue
}
if !cs.covers(sel) { if !cs.covers(sel) {
continue continue
} }
@@ -642,70 +501,6 @@ func sortTasks(tasks []TaskRow) {
}) })
} }
// assigneeStrip 把一格里所有任务的打手颜色去重后返回,未指派用灰色占位。
func assigneeStrip(tickets []Ticket) []string {
if len(tickets) == 0 {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, 4)
for _, t := range tickets {
c := t.AssigneeColor
if c == "" {
c = "#8A8F98"
}
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
// blockCellText 整段型订阅在 date 所在周期的完成文本:
// 通行证 0/1 或 1/1;深渊按 date 所在的那一个周期 0/1 或 1/1,不显示全局 x/N。
func blockCellText(cs calSub, date string) string {
if cs.sub.Abyss {
if cs.cycleDone(date) {
return "1/1"
}
return "0/1"
}
if cs.sub.SeasonDone != "" {
return "1/1"
}
return "0/1"
}
// blockCellPercent 整段型色块浓度:通行证 18%;深渊按所在周期 34%→4%。
func blockCellPercent(cs calSub, day time.Time) int {
if cs.sub.Abyss && cs.sub.CycleDays > 0 {
idx := domain.CycleIndex(cs.start, day, cs.sub.CycleDays)
return abyssBlockPercent(idx, cs.sub.TotalCount)
}
return 18
}
// abyssBlockPercent 深渊色块浓度:第一个周期 34%,之后每个周期依次减淡,
// 最后一个周期 4%(周期数大于 1 时线性过渡);跨度比 v1.11.3 的 22%→10% 明显更大。
func abyssBlockPercent(idx, total int) int {
if total <= 1 {
return 34
}
if idx < 0 {
idx = 0
}
if idx >= total {
idx = total - 1
}
pct := 34 - (idx*30)/(total-1)
if pct < 4 {
pct = 4
}
return pct
}
func heatLevel(n int) int { func heatLevel(n int) int {
switch { switch {
case n <= 0: case n <= 0:
+8 -58
View File
@@ -58,13 +58,11 @@ func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
type customerFormData struct { type customerFormData struct {
Customer *store.Customer Customer *store.Customer
IsNew bool IsNew bool
Users []store.User
} }
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers") p := s.newPage(r, "新建客户", "customers")
users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true, Users: users}
s.render(w, r, "customer_form", p) s.render(w, r, "customer_form", p)
} }
@@ -78,7 +76,6 @@ func (s *Server) readCustomerForm(r *http.Request) *store.Customer {
Tags: normalizeTags(r.PostFormValue("tags")), Tags: normalizeTags(r.PostFormValue("tags")),
Source: strings.TrimSpace(r.PostFormValue("source")), Source: strings.TrimSpace(r.PostFormValue("source")),
Note: strings.TrimSpace(r.PostFormValue("note")), Note: strings.TrimSpace(r.PostFormValue("note")),
AssigneeID: int64(s.formInt(r, "assignee_id", 0)),
} }
} }
@@ -117,8 +114,7 @@ func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
c := s.readCustomerForm(r) c := s.readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers") p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" { if msg := validateCustomer(c); msg != "" {
users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: c, IsNew: true}
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p) s.fail(w, r, "customer_form", msg, p)
return return
} }
@@ -128,8 +124,7 @@ func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, store.ErrDuplicate) { if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅" msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
} }
users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: c, IsNew: true}
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p) s.fail(w, r, "customer_form", msg, p)
return return
} }
@@ -191,7 +186,6 @@ type customerDetailData struct {
AdjSubColor map[int64]string AdjSubColor map[int64]string
Checkins []CheckinRow Checkins []CheckinRow
Packages []store.Package Packages []store.Package
Users []store.User
CustCal *custCal CustCal *custCal
Timeline *Timeline Timeline *Timeline
Extend *ExtendPanel Extend *ExtendPanel
@@ -240,10 +234,10 @@ func (s *Server) buildCustomerCal(c *store.Customer, cm string, today time.Time)
} }
pauses, _ := s.db.PausesForSubs(ids) pauses, _ := s.db.PausesForSubs(ids)
checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1))) checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1)))
warn := s.warnDays()
list := make([]calSub, 0, len(subs)) list := make([]calSub, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn), checkins: checkins[sub.ID]} cs := calSub{sub: sub, pauses: pauses[sub.ID], checkins: checkins[sub.ID]}
cs.start = domain.ParseDateOr(sub.StartDate, today)
if sub.EndDate != "" { if sub.EndDate != "" {
cs.hasEnd = true cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today) cs.end = domain.ParseDateOr(sub.EndDate, today)
@@ -322,7 +316,6 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
} }
pauses, _ := s.db.PausesForSubs(ids) pauses, _ := s.db.PausesForSubs(ids)
users, _ := s.db.ActiveUsers()
// 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加 // 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加
owned := map[int64]bool{} owned := map[int64]bool{}
for _, sub := range subs { for _, sub := range subs {
@@ -344,7 +337,6 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
return sorted[i].ID < sorted[j].ID return sorted[i].ID < sorted[j].ID
}) })
data := &customerDetailData{ data := &customerDetailData{
Users: users,
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today), CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
Customer: c, Customer: c,
Subs: subViews(sorted, today, warn), Subs: subViews(sorted, today, warn),
@@ -384,7 +376,7 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "customer_detail", p) s.render(w, r, "customer_detail", p)
} }
// handleCustomerArchive 停用客户:默认从列表、日历、大屏隐藏,订阅保留。 // handleCustomerArchive 停用客户:默认从列表与日历隐藏,订阅保留。
func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) {
id := pathID(r) id := pathID(r)
c, err := s.db.Customer(id) c, err := s.db.Customer(id)
@@ -436,31 +428,7 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name) s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name)
} }
// handleCustomerAssignee 从客户详情快速指派/取消打手。 // handleSubCreate 给客户开通一个按日套餐。
func (s *Server) handleCustomerAssignee(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
userID := int64(s.formInt(r, "assignee_id", 0))
if err := s.db.SetCustomerAssignee(id, userID); err != nil {
s.redirect(w, r, "/customers/"+itoa(id), "指派失败:"+err.Error())
return
}
c, _ := s.db.Customer(id)
word := "已取消指派"
if userID > 0 {
if u, err := s.db.UserByID(userID); err == nil {
name := u.DisplayName
if name == "" {
name = u.Username
}
word = "已指派给 " + name
}
}
if c != nil {
s.logOp(r, "指派打手", "customer", id, c.Tail+" "+c.Name+" "+word)
}
s.redirect(w, r, "/customers/"+itoa(id), word)
}
func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
customerID := pathID(r) customerID := pathID(r)
c, err := s.db.Customer(customerID) c, err := s.db.Customer(customerID)
@@ -474,7 +442,6 @@ func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
StartDate: r.PostFormValue("start_date"), StartDate: r.PostFormValue("start_date"),
EndDate: r.PostFormValue("end_date"), EndDate: r.PostFormValue("end_date"),
Days: s.formInt(r, "days", 0), Days: s.formInt(r, "days", 0),
TotalCount: s.formInt(r, "total_count", 0),
Note: strings.TrimSpace(r.PostFormValue("note")), Note: strings.TrimSpace(r.PostFormValue("note")),
} }
u := s.currentUser(r) u := s.currentUser(r)
@@ -487,27 +454,10 @@ func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
s.logOp(r, "新建订阅", "subscription", sub.ID, s.logOp(r, "新建订阅", "subscription", sub.ID,
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+subTypeLabel(sub)+")") "为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"(按日)")
s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」") s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」")
} }
func unitLabel(u string) string {
if u == domain.UnitCount {
return "按次"
}
return "按日"
}
func subTypeLabel(sub *store.Subscription) string {
switch {
case sub.Abyss:
return "深渊"
case sub.Season:
return "通行证"
}
return unitLabel(sub.Unit)
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) } func itoa(v int64) string { return strconv.FormatInt(v, 10) }
func sortAdjustments(list []store.Adjustment) { func sortAdjustments(list []store.Adjustment) {
+1 -185
View File
@@ -2,7 +2,6 @@ package web
import ( import (
"encoding/csv" "encoding/csv"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -47,16 +46,12 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
type settingsData struct { type settingsData struct {
WarnDays int WarnDays int
ScreenRefresh int
Users []store.User
Backups []string Backups []string
DBPath string DBPath string
BackupDir string BackupDir string
BackupKeep int BackupKeep int
BackupEnabled bool BackupEnabled bool
ConfigPath string ConfigPath string
ScreenPublic bool
PortalPublic bool
Addrs []string Addrs []string
Port string Port string
Uptime string Uptime string
@@ -121,7 +116,6 @@ func LocalAddrs() []string {
} }
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
users, _ := s.db.ListUsers()
backups, _ := s.db.ListBackups() backups, _ := s.db.ListBackups()
custN, _ := s.db.CustomerCount() custN, _ := s.db.CustomerCount()
pkgs, _ := s.db.ListPackages(true) pkgs, _ := s.db.ListPackages(true)
@@ -132,16 +126,12 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
} }
data := &settingsData{ data := &settingsData{
WarnDays: s.warnDays(), WarnDays: s.warnDays(),
ScreenRefresh: s.screenRefresh(),
Users: users,
Backups: backups, Backups: backups,
DBPath: s.db.Path, DBPath: s.db.Path,
BackupDir: s.cfg.BackupDir, BackupDir: s.cfg.BackupDir,
BackupKeep: s.cfg.BackupKeep, BackupKeep: s.cfg.BackupKeep,
BackupEnabled: s.cfg.BackupEnabled, BackupEnabled: s.cfg.BackupEnabled,
ConfigPath: s.cfg.ConfigPath, ConfigPath: s.cfg.ConfigPath,
ScreenPublic: s.cfg.ScreenPublic,
PortalPublic: s.cfg.PortalPublic,
Addrs: LocalAddrs(), Addrs: LocalAddrs(),
Port: port, Port: port,
Uptime: humanDuration(time.Since(s.started)), Uptime: humanDuration(time.Since(s.started)),
@@ -155,118 +145,6 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "settings", p) s.render(w, r, "settings", p)
} }
type userFormData struct {
Target *store.User
}
// isSuperAdmin 只有一个超级管理员(id=1)可以修改他人资料。
func isSuperAdmin(u *store.User) bool { return u != nil && u.ID == 1 }
// handleUserEditPage 超管编辑其他管理员。
func (s *Server) handleUserEditPage(w http.ResponseWriter, r *http.Request) {
me := s.currentUser(r)
if !isSuperAdmin(me) {
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
return
}
id := pathID(r)
if id == me.ID {
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
return
}
u, err := s.db.UserByID(id)
if err != nil {
s.redirect(w, r, "/settings", "账户不存在")
return
}
p := s.newPage(r, "编辑管理员", "settings")
p.D = &userFormData{Target: u}
s.render(w, r, "user_form", p)
}
// handleUserEditSave 保存超管对其他管理员的修改。
func (s *Server) handleUserEditSave(w http.ResponseWriter, r *http.Request) {
me := s.currentUser(r)
if !isSuperAdmin(me) {
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
return
}
id := pathID(r)
if id == me.ID {
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
return
}
u, err := s.db.UserByID(id)
if err != nil {
s.redirect(w, r, "/settings", "账户不存在")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
displayName := strings.TrimSpace(r.PostFormValue("display_name"))
password := r.PostFormValue("password")
password2 := r.PostFormValue("password2")
back := "/settings/users/" + itoa(id) + "/edit"
if l := len([]rune(username)); l < 3 || l > 32 {
s.redirect(w, r, back, "用户名长度需在 3-32 个字符之间")
return
}
if len([]rune(displayName)) > 32 {
s.redirect(w, r, back, "显示名称不能超过 32 个字符")
return
}
if password != "" {
if len([]rune(password)) < 6 {
s.redirect(w, r, back, "新密码至少 6 位")
return
}
if password != password2 {
s.redirect(w, r, back, "两次输入的新密码不一致")
return
}
}
if err := s.db.UpdateUserByAdmin(id, username, displayName, password); err != nil {
msg := "保存失败:" + err.Error()
if errors.Is(err, store.ErrDuplicate) {
msg = "用户名已被占用"
}
s.redirect(w, r, back, msg)
return
}
detail := u.Username + " → " + username
if displayName != u.DisplayName {
detail += ";昵称 " + u.DisplayName + " → " + displayName
}
if password != "" {
detail += ";已重置密码并强制下线"
}
s.logOp(r, "修改管理员资料", "user", id, detail)
s.redirect(w, r, "/settings", "已更新「"+username+"」的资料")
}
// handleUserColor 设置管理员(打手)颜色。
func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
color, err := store.NormalizeColor(r.PostFormValue("color"))
if err != nil {
s.redirect(w, r, "/settings", "颜色格式不正确")
return
}
if err := s.db.SetUserColor(id, color); err != nil {
s.redirect(w, r, "/settings", "保存颜色失败:"+err.Error())
return
}
name := ""
if u, err := s.db.UserByID(id); err == nil {
name = u.DisplayName
if name == "" {
name = u.Username
}
}
s.logOp(r, "设置打手颜色", "user", id, name+" → "+color)
s.redirect(w, r, "/settings", "已更新 "+name+" 的颜色")
}
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
warn := s.formInt(r, "warn_days", 3) warn := s.formInt(r, "warn_days", 3)
if warn < 0 { if warn < 0 {
@@ -275,18 +153,8 @@ func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
if warn > 60 { if warn > 60 {
warn = 60 warn = 60
} }
refresh := s.formInt(r, "screen_refresh", 30)
if refresh < 5 {
refresh = 5
}
if refresh > 600 {
refresh = 600
}
_ = s.db.SetSetting("warn_days", strconv.Itoa(warn)) _ = s.db.SetSetting("warn_days", strconv.Itoa(warn))
_ = s.db.SetSetting("screen_refresh", strconv.Itoa(refresh)) s.logOp(r, "修改设置", "settings", 0, fmt.Sprintf("预警 %d 天", warn))
s.logOp(r, "修改设置", "settings", 0,
fmt.Sprintf("预警 %d 天、大屏刷新 %d 秒", warn, refresh))
s.redirect(w, r, "/settings", "设置已保存") s.redirect(w, r, "/settings", "设置已保存")
} }
@@ -319,58 +187,6 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, "/settings", "密码已更新") s.redirect(w, r, "/settings", "密码已更新")
} }
func (s *Server) handleUserCreate(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username"))
display := strings.TrimSpace(r.PostFormValue("display_name"))
pw := r.PostFormValue("password")
switch {
case len(username) < 3:
s.redirect(w, r, "/settings", "用户名至少 3 个字符")
return
case len(pw) < 6:
s.redirect(w, r, "/settings", "密码至少 6 位")
return
}
if display == "" {
display = username
}
u, err := s.db.CreateUser(username, display, pw)
if err != nil {
if err == store.ErrDuplicate {
s.redirect(w, r, "/settings", "用户名已存在")
return
}
s.redirect(w, r, "/settings", "创建失败:"+err.Error())
return
}
s.logOp(r, "新增管理员", "user", u.ID, "新增管理员 "+username)
s.redirect(w, r, "/settings", "管理员 "+username+" 已创建")
}
func (s *Server) handleUserToggle(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
me := s.currentUser(r)
if me != nil && me.ID == id {
s.redirect(w, r, "/settings", "不能停用自己的账户")
return
}
target, err := s.db.UserByID(id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.db.SetUserActive(id, !target.IsActive); err != nil {
s.redirect(w, r, "/settings", "操作失败:"+err.Error())
return
}
action := "停用账户"
if !target.IsActive {
action = "启用账户"
}
s.logOp(r, action, "user", id, action+" "+target.Username)
s.redirect(w, r, "/settings", action+"成功")
}
func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) {
path, err := s.db.BackupNow(s.cfg.BackupKeep) path, err := s.db.BackupNow(s.cfg.BackupKeep)
if err != nil { if err != nil {
+4 -47
View File
@@ -35,7 +35,7 @@ func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新增套餐", "packages") p := s.newPage(r, "新增套餐", "packages")
tags, _ := s.db.PackageTags() tags, _ := s.db.PackageTags()
p.D = &packageFormData{ p.D = &packageFormData{
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#3B6EA5"}, Package: &store.Package{DefaultAmount: 30, Color: "#3B6EA5"},
IsNew: true, IsNew: true,
Tags: tags, Tags: tags,
} }
@@ -45,7 +45,6 @@ func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
func readPackageForm(r *http.Request) *store.Package { func readPackageForm(r *http.Request) *store.Package {
p := &store.Package{ p := &store.Package{
Name: strings.TrimSpace(r.PostFormValue("name")), Name: strings.TrimSpace(r.PostFormValue("name")),
Unit: strings.TrimSpace(r.PostFormValue("unit")),
DefaultAmount: 0, DefaultAmount: 0,
Color: strings.TrimSpace(r.PostFormValue("color")), Color: strings.TrimSpace(r.PostFormValue("color")),
Note: strings.TrimSpace(r.PostFormValue("note")), Note: strings.TrimSpace(r.PostFormValue("note")),
@@ -63,61 +62,20 @@ func readPackageForm(r *http.Request) *store.Package {
p.HasPrice = true p.HasPrice = true
} }
} }
if p.Unit == "" {
p.Unit = domain.UnitDay
}
p.CalendarSeparate = r.PostFormValue("calendar_separate") != "" p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
p.Tag = strings.TrimSpace(r.PostFormValue("tag")) p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
switch p.Unit {
case "season":
p.Season = true
p.Unit = domain.UnitDay
case "abyss":
p.Abyss = true
p.Unit = domain.UnitDay
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("cycle_days"))); err == nil {
p.CycleDays = v
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_validity_days"))); err == nil {
p.DefaultValidityDays = v
}
p.SeasonStart = strings.TrimSpace(r.PostFormValue("season_start"))
p.SeasonEnd = strings.TrimSpace(r.PostFormValue("season_end"))
if p.Season && p.DefaultAmount <= 0 {
p.DefaultAmount = 1
}
return p return p
} }
func validatePackage(p *store.Package) string { func validatePackage(p *store.Package) string {
if p.Season {
if p.SeasonStart == "" || p.SeasonEnd == "" {
return "通行证套餐必须填写开始日和结束日"
}
if p.SeasonEnd < p.SeasonStart {
return "通行证结束日不能早于开始日"
}
}
if p.Abyss {
if p.CycleDays < 1 || p.CycleDays > 365 {
return "深渊模式的周期天数必须在 1-365 之间"
}
if p.DefaultAmount < 1 || p.DefaultAmount > 1000 {
return "深渊模式的周期数必须在 1-1000 之间"
}
}
if p.Name == "" { if p.Name == "" {
return "套餐名称不能为空" return "套餐名称不能为空"
} }
if len([]rune(p.Name)) > 32 { if len([]rune(p.Name)) > 32 {
return "套餐名称最长 32 字" return "套餐名称最长 32 字"
} }
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
return "套餐类型不正确"
}
if p.DefaultAmount <= 0 { if p.DefaultAmount <= 0 {
return "默认天数/次数必须大于 0" return "默认天数必须大于 0"
} }
if p.Color == "" || !strings.HasPrefix(p.Color, "#") { if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
return "请选择套餐颜色" return "请选择套餐颜色"
@@ -178,7 +136,7 @@ func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Requ
return return
} }
start := strings.TrimSpace(r.PostFormValue("start_date")) start := strings.TrimSpace(r.PostFormValue("start_date"))
if start == "" && !pkg.Season { if start == "" {
start = domain.Date(domain.Today()) start = domain.Date(domain.Today())
} }
if start != "" { if start != "" {
@@ -189,7 +147,6 @@ func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Requ
} }
end := strings.TrimSpace(r.PostFormValue("end_date")) end := strings.TrimSpace(r.PostFormValue("end_date"))
days := s.formInt(r, "days", 0) days := s.formInt(r, "days", 0)
count := s.formInt(r, "total_count", 0)
u := s.currentUser(r) u := s.currentUser(r)
var op int64 var op int64
if u != nil { if u != nil {
@@ -206,7 +163,7 @@ func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Requ
} }
_, err = s.db.CreateSub(store.CreateSubInput{ _, err = s.db.CreateSub(store.CreateSubInput{
CustomerID: cid, PackageID: id, StartDate: start, EndDate: end, CustomerID: cid, PackageID: id, StartDate: start, EndDate: end,
Days: days, TotalCount: count, OperatorID: op, Days: days, OperatorID: op,
}) })
if err != nil { if err != nil {
if strings.Contains(err.Error(), "已订阅") { if strings.Contains(err.Error(), "已订阅") {
-390
View File
@@ -1,390 +0,0 @@
package web
import (
"fmt"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"daili/internal/domain"
"daili/internal/store"
)
// portalDay 客户自助月历中的一天:日常任务按 done/total 计,通行证/深渊按整段色块展示。
type portalDay struct {
Date string
Day int
InMonth bool
Today bool
Total int
Done int
// 通行证 / 深渊色块
BlockColor string
BlockText string
BlockTitle string
BlockPercent int
// Label 是读屏用的整格描述
Label string
}
// portalSub 客户自助页里的一个订阅;复用管理端 SubView 的状态/剩余文案。
type portalSub struct {
SubView
TodayLabel string
TodayClass string
}
// portalAsk 同一尾号命中多个客户时要求补姓名。
type portalAsk struct {
Tail string
Count int
Masked []string
}
// portalView 客户自助查询结果。
type portalView struct {
Tail string
Name string
Game string
Subs []portalSub
Weeks [][]portalDay
MonthTitle string
Prev string
Next string
TodayTotal int
TodayDone int
TodayPending int
TodayState string // done / pending / none
SubCount int
Expiring string
Masked []string
}
var portalTailRe = regexp.MustCompile(`^\d{4,8}$`)
const (
portalRateLimit = 40
portalRateWindow = time.Minute
)
var portalHits = struct {
sync.Mutex
m map[string][]time.Time
}{m: map[string][]time.Time{}}
// allowPortal 对自助查询做简单的按 IP 限流,避免尾号枚举把库打穿。
func allowPortal(remote string) bool {
host, _, err := net.SplitHostPort(remote)
if err != nil || host == "" {
host = remote
}
now := time.Now()
portalHits.Lock()
defer portalHits.Unlock()
kept := portalHits.m[host][:0]
for _, t := range portalHits.m[host] {
if now.Sub(t) < portalRateWindow {
kept = append(kept, t)
}
}
if len(kept) >= portalRateLimit {
portalHits.m[host] = kept
return false
}
portalHits.m[host] = append(kept, now)
return true
}
// handleCustomerPortal 客户自助查询页(公开,无入口链接)。
func (s *Server) handleCustomerPortal(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
p := s.newPage(r, "排单查询", "")
q := r.URL.Query()
tail := strings.TrimSpace(q.Get("tail"))
name := strings.TrimSpace(q.Get("name"))
month := strings.TrimSpace(q.Get("cm"))
p.Query = tail
p.Name = name
if tail == "" {
s.render(w, r, "portal", p)
return
}
if !allowPortal(r.RemoteAddr) {
p.InlineError = "查询太频繁,请稍后再试"
s.render(w, r, "portal", p)
return
}
if !portalTailRe.MatchString(tail) {
p.InlineError = "请输入 4–8 位数字手机尾号"
p.InvalidField = "tail"
s.render(w, r, "portal", p)
return
}
list, err := s.db.CustomersByTailExact(tail)
if err != nil {
p.InlineError = "读取客户失败,请稍后再试"
s.render(w, r, "portal", p)
return
}
if len(list) == 0 {
p.InlineError = "没有找到这个尾号的客户,请核对尾号"
p.InvalidField = "tail"
s.render(w, r, "portal", p)
return
}
if len(list) > 1 {
if name == "" {
p.D = &portalAsk{Tail: tail, Count: len(list), Masked: maskNames(list)}
s.render(w, r, "portal", p)
return
}
matched := matchByName(list, name)
if len(matched) == 0 {
p.InlineError = "尾号对得上,但姓名不匹配,请再确认一下"
p.InvalidField = "name"
s.render(w, r, "portal", p)
return
}
if len(matched) > 1 {
p.D = &portalAsk{Tail: tail, Count: len(matched), Masked: maskNames(matched)}
s.render(w, r, "portal", p)
return
}
list = matched
}
c := list[0]
p.D = s.buildPortal(c, tail, name, month)
s.render(w, r, "portal", p)
}
func matchByName(list []store.Customer, name string) []store.Customer {
needle := strings.ToLower(strings.TrimSpace(name))
out := make([]store.Customer, 0, len(list))
for _, c := range list {
if strings.Contains(strings.ToLower(c.Name), needle) {
out = append(out, c)
}
}
return out
}
func maskNames(list []store.Customer) []string {
out := make([]string, 0, len(list))
seen := map[string]bool{}
for _, c := range list {
m := maskName(c.Name)
if seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
// maskName 只保留首字,避免多客户态直接列出全名。
func maskName(name string) string {
rs := []rune(strings.TrimSpace(name))
switch len(rs) {
case 0:
return "*"
case 1:
return "*"
}
return string(rs[0]) + strings.Repeat("*", len(rs)-1)
}
// buildPortal 组装某位客户的排单视图。
func (s *Server) buildPortal(c store.Customer, tail, name, month string) *portalView {
today := domain.Today()
todayStr := domain.Date(today)
warn := s.warnDays()
first := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC)
if month != "" {
if t, err := time.Parse("2006-01", month); err == nil {
first = t
}
}
year, mon := first.Year(), int(first.Month())
firstDay := time.Date(year, time.Month(mon), 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, 1, -1)
start := firstDay.AddDate(0, 0, -((int(firstDay.Weekday()) + 6) % 7))
end := lastDay.AddDate(0, 0, 6-((int(lastDay.Weekday())+6)%7))
allSubs, _ := s.db.SubsByCustomer(c.ID)
subs := make([]store.Subscription, 0, len(allSubs))
ids := make([]int64, 0, len(allSubs))
for _, sub := range allSubs {
if sub.Status == domain.StatusClosed {
continue
}
subs = append(subs, sub)
ids = append(ids, sub.ID)
}
cks, _ := s.db.CheckinsForSubs(ids, wideFrom(domain.Date(start)), wideTo(domain.Date(end)))
pauses, _ := s.db.PausesForSubs(ids)
cals := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
checkins: cks[sub.ID],
}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
cals = append(cals, cs)
}
v := &portalView{
Tail: c.Tail,
Name: c.Name,
Game: c.GameName,
SubCount: len(subs),
}
v.MonthTitle = fmt.Sprintf("%d 年 %d 月", year, mon)
v.Prev = portalQuery(tail, name, firstDay.AddDate(0, -1, 0).Format("2006-01"))
v.Next = portalQuery(tail, name, firstDay.AddDate(0, 1, 0).Format("2006-01"))
for _, cs := range cals {
sub := cs.sub
ev := newSubView(sub, today, warn)
ps := portalSub{SubView: ev}
if ev.StateClass == "active" && !sub.Season && !sub.Abyss && cs.covers(todayStr) {
if ci, ok := cks[sub.ID][todayStr]; ok && portalDone(ci) {
ps.TodayLabel, ps.TodayClass = "今日已完成", "active"
} else {
ps.TodayLabel, ps.TodayClass = "今日未完成", "plain"
}
}
v.Subs = append(v.Subs, ps)
if sub.EndDate != "" && sub.EndDate >= todayStr {
switch ev.Eval.State {
case domain.StateExpired, domain.StateUsedUp, domain.StateClosed:
default:
if v.Expiring == "" || sub.EndDate < v.Expiring {
v.Expiring = sub.EndDate
}
}
}
}
// 月历
days := int(end.Sub(start).Hours()/24) + 1
week := make([]portalDay, 0, 7)
for i := 0; i < days; i++ {
d := start.AddDate(0, 0, i)
ds := domain.Date(d)
pd := portalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(mon), Today: ds == todayStr}
// 与主日历一致:只在本月格子里显示任务,前后月只做日期占位。
if pd.InMonth {
for _, cs := range cals {
sub := cs.sub
if cs.isBlock() {
if cs.blockCovers(ds) && pd.BlockText == "" {
pd.BlockColor = sub.PackageColor
pd.BlockText = blockCellText(cs, ds)
pd.BlockTitle = sub.PackageName + " · " + pd.BlockText
pd.BlockPercent = blockCellPercent(cs, d)
}
continue
}
if !cs.covers(ds) {
continue
}
pd.Total++
if ci, ok := cks[sub.ID][ds]; ok && portalDone(ci) {
pd.Done++
}
}
}
pd.Label = portalDayLabel(pd, mon)
week = append(week, pd)
if len(week) == 7 {
v.Weeks = append(v.Weeks, week)
week = make([]portalDay, 0, 7)
}
}
if len(week) > 0 {
v.Weeks = append(v.Weeks, week)
}
// 今日进度:整段型订阅算 1 项,通行证看 season_done,深渊看当天所在周期。
for _, cs := range cals {
if cs.isBlock() {
if cs.blockCovers(todayStr) {
v.TodayTotal++
if !cs.blockNeedsWork(todayStr) {
v.TodayDone++
}
}
continue
}
if !cs.covers(todayStr) {
continue
}
v.TodayTotal++
if ci, ok := cks[cs.sub.ID][todayStr]; ok && portalDone(ci) {
v.TodayDone++
}
}
v.TodayPending = v.TodayTotal - v.TodayDone
switch {
case v.TodayTotal == 0:
v.TodayState = "none"
case v.TodayPending <= 0:
v.TodayState = "done"
default:
v.TodayState = "pending"
}
return v
}
// portalDone 与管理端日历口径一致:done 和 skipped 都算当天已处理。
func portalDone(ci store.Checkin) bool {
return ci.Status == "done" || ci.Status == "skipped"
}
func portalDayLabel(pd portalDay, month int) string {
labelMonth := month
if t, err := time.Parse("2006-01-02", pd.Date); err == nil {
labelMonth = int(t.Month())
}
label := fmt.Sprintf("%d 月 %d 日", labelMonth, pd.Day)
if pd.Today {
label += ",今天"
}
if pd.BlockText != "" {
label += "," + pd.BlockTitle
}
if pd.Total > 0 {
label += fmt.Sprintf(",共 %d 单,已完成 %d 单", pd.Total, pd.Done)
}
if pd.BlockText == "" && pd.Total == 0 {
label += ",无排单"
}
return label
}
// portalQuery 用 url.Values 拼翻月链接,姓名含 & / 空格时也不会丢参数。
func portalQuery(tail, name, month string) string {
q := url.Values{}
q.Set("tail", tail)
if name != "" {
q.Set("name", name)
}
q.Set("cm", month)
return "/customer?" + q.Encode()
}
-326
View File
@@ -1,326 +0,0 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type screenDay struct {
Date string
Day int
InMonth bool
IsToday bool
IsPast bool
Count int
Done int
Urgent int
Heat int
// 通行证 / 深渊整段色块:BlockText 是该周期的 1/1 或 0/1
BlockColor string
BlockText string
BlockTitle string
BlockPercent int
// Strip 打手色竖条(去重)
Strip []string
}
type screenTask struct {
TaskRow
}
type screenData struct {
Stats *store.Stats
MonthTitle string
Year, Month int
Weeks [][]screenDay
Weekdays []string
Top []SubView
DueToday []SubView
TodayList []screenTask
Recent []store.Adjustment
TodayTasks int
TodayDone int
TodayUrgent int
Refresh int
Version string
AssetVersion string
HeatMode string // task = 每日待完成;due = 到期
Tag string
HeatTotal int
Tabs []PkgTab
PkgID int64
HasTabs bool
FilterQuery string
Theme string
}
// screenCalSubs 读取大屏日历区间内的订阅,并带上暂停/打卡/加急。
func (s *Server) screenCalSubs(from, to string, pkgID int64, tag string) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID, tag)
if err != nil {
return nil, nil, nil, err
}
today := domain.Today()
warn := s.warnDays()
list := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
pauses: pauses[sub.ID],
checkins: checkins[sub.ID],
eval: sub.Eval(today, warn),
}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
list = append(list, cs)
}
return list, checkins, urgent, nil
}
func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
pkgID := int64(s.queryInt(r, "pkg", 0))
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
heatMode := r.URL.Query().Get("heat")
if heatMode != "due" {
heatMode = "task"
}
stats, err := s.db.ScreenStats(today, warn)
if err != nil {
http.Error(w, "读取大屏数据失败", http.StatusInternalServerError)
return
}
data := &screenData{
Stats: stats,
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
Year: today.Year(),
Month: int(today.Month()),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Top: subViews(stats.Top, today, warn),
Refresh: s.screenRefresh(),
Version: s.cfg.Version,
AssetVersion: s.assetVersion,
HeatMode: heatMode,
PkgID: pkgID,
Tag: tag,
FilterQuery: "pkg=" + itoa(pkgID),
}
// 今日到期(可按套餐过滤)
dueToday := []store.Subscription{}
for _, sub := range stats.DueTodaySubs {
if pkgID > 0 && sub.PackageID != pkgID {
continue
}
dueToday = append(dueToday, sub)
}
data.DueToday = subViews(dueToday, today, warn)
// 本月热力:默认统计“每日待完成的单子”,可切换到“到期”
monthStart := domain.MonthStart(today.Year(), today.Month())
monthEnd := domain.MonthEnd(today.Year(), today.Month())
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag)
if err != nil {
http.Error(w, "读取大屏日历失败", http.StatusInternalServerError)
return
}
for wi := 0; wi < weeks; wi++ {
week := make([]screenDay, 0, 7)
for di := 0; di < 7; di++ {
d := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(d)
count, doneN, urgentN := 0, 0, 0
strip := []string{}
seenColor := map[string]bool{}
sd := screenDay{
Date: ds,
Day: d.Day(),
InMonth: d.Month() == today.Month(),
IsToday: ds == domain.Date(today),
IsPast: d.Before(today),
}
for _, cs := range list {
if heatMode == "due" {
if cs.sub.EndDate == ds {
count++
}
}
// 通行证 / 深渊是整段型:不计入每日待完成热力,但用整段色块显示周期状态。
if cs.isBlock() {
if cs.blockCovers(ds) && sd.BlockText == "" {
sd.BlockColor = cs.sub.PackageColor
sd.BlockText = blockCellText(cs, ds)
sd.BlockTitle = cs.sub.PackageName + " · " + sd.BlockText
sd.BlockPercent = blockCellPercent(cs, d)
}
continue
}
if heatMode == "due" {
continue
}
if !cs.covers(ds) {
continue
}
ci, has := checkins[cs.sub.ID][ds]
if has && (ci.Status == "done" || ci.Status == "skipped") {
doneN++
continue // “待完成”热力只统计还没处理的
}
count++
if urgent[cs.sub.ID][ds] {
urgentN++
}
color := cs.sub.AssigneeColor
if color == "" {
color = "#8A8F98"
}
if !seenColor[color] {
seenColor[color] = true
strip = append(strip, color)
}
}
if d.Month() == today.Month() {
data.HeatTotal += count
}
sd.Count = count + doneN
sd.Done = doneN
sd.Urgent = urgentN
sd.Heat = heatLevel(count)
sd.Strip = strip
week = append(week, sd)
}
data.Weeks = append(data.Weeks, week)
}
// 今日待打(加急置顶)
rows, err := s.pendingTasks(domain.Date(today), nil, pkgID, tag)
if err == nil {
for _, row := range rows {
if row.Done || row.Skipped {
continue
}
data.TodayList = append(data.TodayList, screenTask{TaskRow: row})
if row.Urgent {
data.TodayUrgent++
}
}
}
data.TodayTasks = len(data.TodayList)
for _, row := range rows {
if row.Done {
data.TodayDone++
}
}
// 最近追加(底部细条)
if adj, err := s.db.RecentAdjustments(6); err == nil {
data.Recent = adj
}
// 页签:按标签合并 + 独立显示另开;通行证 / 深渊也放进大屏,用整段色块显示周期状态
pkgs, _ := s.db.ListPackages(true)
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, nil, 0)
for i := range data.Tabs {
t := &data.Tabs[i]
href := "/screen?heat=" + heatMode
if t.Tag != "" {
href += "&tag=" + url.QueryEscape(t.Tag)
t.Active = t.Tag == tag
} else if t.ID > 0 {
href += "&pkg=" + itoa(t.ID)
t.Active = t.ID == pkgID
} else {
t.Active = tag == "" && pkgID == 0
}
t.Href = href
}
if tag != "" {
data.FilterQuery = "tag=" + url.QueryEscape(tag)
}
data.Theme = themeFromRequest(r)
p := s.newPage(r, "大屏", "screen")
p.Theme = data.Theme
p.D = data
s.render(w, r, "screen", p)
}
// handleScreenDay 返回某天的待办明细,供大屏悬停浮层使用。
func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if _, err := domain.ParseDate(date); err != nil {
http.Error(w, "日期不正确", http.StatusBadRequest)
return
}
pkgID := int64(s.queryInt(r, "pkg", 0))
rows, err := s.pendingTasks(date, nil, pkgID, strings.TrimSpace(r.URL.Query().Get("tag")))
if err != nil {
http.Error(w, "读取失败", http.StatusInternalServerError)
return
}
type item struct {
Tail string `json:"tail"`
Name string `json:"name"`
Pkg string `json:"pkg"`
Color string `json:"color"`
Remain string `json:"remain"`
Urgent bool `json:"urgent"`
Done bool `json:"done"`
Who string `json:"who"`
WhoCol string `json:"who_color"`
}
out := struct {
Date string `json:"date"`
Total int `json:"total"`
Done int `json:"done"`
Urgent int `json:"urgent"`
More int `json:"more"`
Items []item `json:"items"`
}{Date: date}
const limit = 60
for _, row := range rows {
out.Total++
if row.Done || row.Skipped {
out.Done++
}
if row.Urgent {
out.Urgent++
}
if len(out.Items) >= limit {
out.More++
continue
}
out.Items = append(out.Items, item{
Tail: row.CustomerTail,
Name: row.CustomerName,
Pkg: row.PackageName,
Color: row.PackageColor,
Remain: row.RemainText,
Urgent: row.Urgent,
Done: row.Done || row.Skipped,
Who: row.AssigneeLabel(),
WhoCol: row.AssigneeHex(),
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(out)
}
-54
View File
@@ -72,16 +72,6 @@ func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
note := strings.TrimSpace(r.PostFormValue("note")) note := strings.TrimSpace(r.PostFormValue("note"))
back := backURL(r, "/customers/"+itoa(sub.CustomerID)) back := backURL(r, "/customers/"+itoa(sub.CustomerID))
if sub.Season {
s.redirect(w, r, back, "通行证不支持追加时长,请修改套餐或新建订阅")
return
}
if sub.Abyss {
s.redirect(w, r, back, "深渊模式按周期计数,不支持追加时长")
return
}
if sub.Unit == domain.UnitDay {
newEnd := strings.TrimSpace(r.PostFormValue("new_end_date")) newEnd := strings.TrimSpace(r.PostFormValue("new_end_date"))
plan, err := s.db.ExtendDays(id, newEnd, note, opID) plan, err := s.db.ExtendDays(id, newEnd, note, opID)
if err != nil { if err != nil {
@@ -91,17 +81,6 @@ func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
s.logOp(r, "追加时长", "subscription", id, fmt.Sprintf("%s 追加 %d 天:%s → %s(起点 %s)", s.logOp(r, "追加时长", "subscription", id, fmt.Sprintf("%s 追加 %d 天:%s → %s(起点 %s)",
sub.SubTitle(), plan.Days, sub.EndDate, domain.Date(plan.NewEnd), domain.Date(plan.Base))) sub.SubTitle(), plan.Days, sub.EndDate, domain.Date(plan.NewEnd), domain.Date(plan.Base)))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 天,新结束日 %s", plan.Days, domain.Date(plan.NewEnd))) s.redirect(w, r, back, fmt.Sprintf("已追加 %d 天,新结束日 %s", plan.Days, domain.Date(plan.NewEnd)))
return
}
add := s.formInt(r, "add_count", 0)
optEnd := strings.TrimSpace(r.PostFormValue("new_end_opt"))
if err := s.db.AddCount(id, add, optEnd, note, opID); err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加次数", "subscription", id, fmt.Sprintf("%s 追加 %d 次", sub.SubTitle(), add))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 次", add))
} }
func (s *Server) handleSubAction(action string) http.HandlerFunc { func (s *Server) handleSubAction(action string) http.HandlerFunc {
@@ -169,19 +148,6 @@ func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
if u != nil { if u != nil {
opID = u.ID opID = u.ID
} }
if sub.Season {
if status == "skipped" {
s.redirect(w, r, back, "通行证不能跳过,请直接完成或结束")
return
}
if err := s.db.SeasonComplete(id, date); err != nil {
s.redirect(w, r, back, "完成通行证失败:"+err.Error())
return
}
s.logOp(r, "通行证完成", "subscription", id, fmt.Sprintf("%s 通行证整段完成(%s)", sub.SubTitle(), date))
s.redirect(w, r, back, "已把 "+sub.CustomerTail+" "+sub.CustomerName+" 的通行证标记为完成")
return
}
if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil { if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil {
s.redirect(w, r, back, "打卡失败:"+err.Error()) s.redirect(w, r, back, "打卡失败:"+err.Error())
return return
@@ -225,23 +191,3 @@ func (s *Server) handleSubDelete(w http.ResponseWriter, r *http.Request) {
s.logOp(r, "删除订阅", "subscription", id, sub.SubTitle()+"(含打卡与追加记录)") s.logOp(r, "删除订阅", "subscription", id, sub.SubTitle()+"(含打卡与追加记录)")
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已删除订阅「"+sub.PackageName+"」") s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已删除订阅「"+sub.PackageName+"」")
} }
// handleSeasonDone 从订阅卡片直接完成整段通行证。
func (s *Server) handleSeasonDone(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
date := strings.TrimSpace(r.PostFormValue("date"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
if err := s.db.SeasonComplete(id, date); err != nil {
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "完成失败:"+err.Error())
return
}
s.logOp(r, "通行证完成", "subscription", id, sub.SubTitle()+" 整段完成("+date+")")
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已把通行证标记为完成")
}
+3 -25
View File
@@ -9,7 +9,7 @@ import (
"daili/internal/domain" "daili/internal/domain"
) )
// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余), // pendingTasks 返回某天“应服务”的订阅行(在区间内且未暂停),
// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。 // customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。
func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64, tag string) ([]TaskRow, error) { func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64, tag string) ([]TaskRow, error) {
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag) subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag)
@@ -23,26 +23,11 @@ func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int
if customerIDs != nil && !customerIDs[sub.CustomerID] { if customerIDs != nil && !customerIDs[sub.CustomerID] {
continue continue
} }
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn), checkins: checkins[sub.ID]} cs := calSub{sub: sub, pauses: pauses[sub.ID], checkins: checkins[sub.ID]}
if sub.EndDate != "" { if sub.EndDate != "" {
cs.hasEnd = true cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today) cs.end = domain.ParseDateOr(sub.EndDate, today)
} }
if cs.isBlock() {
if !cs.blockNeedsWork(date) {
continue
}
row := TaskRow{SubView: newSubView(sub, today, warn), Urgent: urgent[sub.ID][date]}
if ci, ok := checkins[sub.ID][date]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
rows = append(rows, row)
continue
}
if !cs.covers(date) { if !cs.covers(date) {
continue continue
} }
@@ -60,15 +45,8 @@ func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int
return rows, nil return rows, nil
} }
// completeTask 完成某个订阅的某一天:通行证走整段完成,其余写打卡。 // completeTask 完成某个订阅的某一天。
func (s *Server) completeTask(subID int64, date string, operatorID int64) error { func (s *Server) completeTask(subID int64, date string, operatorID int64) error {
sub, err := s.db.Sub(subID)
if err != nil {
return err
}
if sub.Season {
return s.db.SeasonComplete(subID, date)
}
return s.db.SaveCheckin(subID, date, "done", "", operatorID) return s.db.SaveCheckin(subID, date, "done", "", operatorID)
} }
+2 -45
View File
@@ -23,7 +23,7 @@ import (
"daili/internal/store" "daili/internal/store"
) )
//go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html //go:embed templates/_layout.html templates/_partials.html templates/pages/*.html
var templateFS embed.FS var templateFS embed.FS
//go:embed static //go:embed static
@@ -44,8 +44,6 @@ type Config struct {
BackupKeep int BackupKeep int
BackupDir string BackupDir string
BackupEnabled bool BackupEnabled bool
ScreenPublic bool
PortalPublic bool
ConfigPath string ConfigPath string
Version string Version string
} }
@@ -129,7 +127,6 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail)) mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage)) mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate)) mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
mux.HandleFunc("POST /customers/{id}/assignee", s.auth(s.handleCustomerAssignee))
mux.HandleFunc("POST /customers/{id}/archive", s.auth(s.handleCustomerArchive)) mux.HandleFunc("POST /customers/{id}/archive", s.auth(s.handleCustomerArchive))
mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore)) mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore))
mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete)) mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete))
@@ -141,7 +138,6 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close"))) mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen"))) mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete)) mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete))
mux.HandleFunc("POST /subscriptions/{id}/season-done", s.auth(s.handleSeasonDone))
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave)) mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete)) mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch)) mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
@@ -155,27 +151,10 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive)) mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail)) mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail))
mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe)) mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe))
if s.cfg.PortalPublic {
// 客户自助查询:独立开关,不受大屏 screen_public 影响
mux.HandleFunc("GET /customer", s.handleCustomerPortal)
}
if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /screen", s.handleScreen)
mux.HandleFunc("GET /api/screen/day", s.handleScreenDay)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
mux.HandleFunc("GET /api/screen/day", s.auth(s.handleScreenDay))
}
mux.HandleFunc("GET /logs", s.auth(s.handleLogs)) mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
mux.HandleFunc("GET /settings", s.auth(s.handleSettings)) mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave)) mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange)) mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor))
mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage))
mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow)) mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport)) mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
@@ -316,11 +295,6 @@ func newRenderer() (*renderer, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
screenBase, err := template.New("screen").Funcs(funcs).ParseFS(templateFS,
"templates/_screen_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{} pages := map[string]*template.Template{}
entries, err := fs.Glob(templateFS, "templates/pages/*.html") entries, err := fs.Glob(templateFS, "templates/pages/*.html")
@@ -329,13 +303,7 @@ func newRenderer() (*renderer, error) {
} }
for _, p := range entries { for _, p := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html") name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html")
var baseSet *template.Template clone, err := base.Clone()
if name == "screen" {
baseSet = screenBase
} else {
baseSet = base
}
clone, err := baseSet.Clone()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -396,14 +364,6 @@ func (s *Server) warnDays() int {
return v return v
} }
func (s *Server) screenRefresh() int {
v, err := strconv.Atoi(s.db.Setting("screen_refresh", "30"))
if err != nil || v < 5 {
return 30
}
return v
}
func (s *Server) newPage(r *http.Request, title, nav string) *page { func (s *Server) newPage(r *http.Request, title, nav string) *page {
u := s.currentUser(r) u := s.currentUser(r)
today := domain.Today() today := domain.Today()
@@ -487,9 +447,6 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *
return return
} }
layout := "layout" layout := "layout"
if name == "screen" {
layout = "screenlayout"
}
var buf bytes.Buffer var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil { if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil {
log.Printf("渲染 %s 失败: %v", name, err) log.Printf("渲染 %s 失败: %v", name, err)
+23 -200
View File
@@ -201,6 +201,28 @@ a:hover { color: var(--app-accent-strong); text-decoration: underline; text-unde
.app-foot a { color: var(--app-text-3); } .app-foot a { color: var(--app-text-3); }
.app-foot a:hover { color: var(--app-accent); } .app-foot a:hover { color: var(--app-accent); }
.app-foot .foot-version { font-family: var(--app-mono); } .app-foot .foot-version { font-family: var(--app-mono); }
.app-foot .foot-by { font-family: var(--app-mono); opacity: 0.85; }
/* ---------- 盲水印(不可见,仅供溯源;勿删) ----------
Powered by Baka春秋
.wm-blind 同时用零尺寸、透明、零透明度和 clip 四重手段确保任何
渲染路径下都不占位、不上色、不进入无障碍树。 */
.wm-blind {
position: absolute !important;
left: -9999px !important;
top: 0 !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
opacity: 0 !important;
color: transparent !important;
font-size: 0 !important;
line-height: 0 !important;
pointer-events: none !important;
user-select: none !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
}
/* ---------- 4. 顶栏 ---------- */ /* ---------- 4. 顶栏 ---------- */
@@ -239,7 +261,6 @@ a:hover { color: var(--app-accent-strong); text-decoration: underline; text-unde
.app-navbar .nav-link.active { .app-navbar .nav-link.active {
background: var(--app-accent-soft); color: var(--app-accent-ink); font-weight: 600; background: var(--app-accent-soft); color: var(--app-accent-ink); font-weight: 600;
} }
.app-navbar .nav-link.nav-screen { color: var(--app-accent); }
.nav-search { position: relative; width: 240px; flex: 0 1 auto; margin: 0 0.5rem; } .nav-search { position: relative; width: 240px; flex: 0 1 auto; margin: 0 0.5rem; }
.nav-search .form-control { height: 32px; padding-right: 30px; } .nav-search .form-control { height: 32px; padding-right: 30px; }
@@ -575,11 +596,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; } .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
.dot.big { width: 12px; height: 12px; margin: 0; } .dot.big { width: 12px; height: 12px; margin: 0; }
.asg { display: inline-flex; align-items: center; gap: 4px; font-size: 0.6875rem; color: var(--app-text-2); white-space: nowrap; }
.asg i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; flex: 0 0 auto; }
.asg.none { color: var(--app-text-3); }
.asg.none i { opacity: 0.55; }
/* ---------- 9. 表格 ---------- */ /* ---------- 9. 表格 ---------- */
.table-wrap { .table-wrap {
@@ -679,15 +695,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
} }
.cust-head .page-actions { grid-column: 2; grid-row: 1; justify-content: flex-end; } .cust-head .page-actions { grid-column: 2; grid-row: 1; justify-content: flex-end; }
.assign-form { display: inline-flex; align-items: center; gap: 6px; }
.assign-form select {
height: 30px; padding: 0 26px 0 8px; max-width: 150px;
border: 1px solid var(--app-border); border-radius: 7px;
background: var(--app-surface); color: var(--app-text);
font: inherit; font-size: 0.75rem;
}
.assign-form select:focus { outline: none; border-color: var(--app-accent); box-shadow: 0 0 0 3px var(--app-ring); }
.subgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; } .subgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.subcard { .subcard {
@@ -796,26 +803,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.cal-cell.heat-4 { background: color-mix(in srgb, var(--app-accent) 18%, transparent); } .cal-cell.heat-4 { background: color-mix(in srgb, var(--app-accent) 18%, transparent); }
.cal-cell.heat-1:hover, .cal-cell.heat-2:hover, .cal-cell.heat-3:hover, .cal-cell.heat-4:hover { background: var(--app-surface-2); } .cal-cell.heat-1:hover, .cal-cell.heat-2:hover, .cal-cell.heat-3:hover, .cal-cell.heat-4:hover { background: var(--app-surface-2); }
/* 通行证 / 深渊:整段连续着色,每格显示同一个「已完成/总数」 */
.cal-cell.has-block {
background: color-mix(in srgb, var(--block) var(--block-pct, 18%), var(--app-surface));
border-color: color-mix(in srgb, var(--block) 24%, var(--app-border));
}
.cal-cell.has-block:hover { background: color-mix(in srgb, var(--block) calc(var(--block-pct, 18%) + 6%), var(--app-surface)); }
.cal-cell.has-block.today .dnum { background: var(--block); color: #fff; }
.cell-block {
display: inline-flex; align-items: center; gap: 4px;
align-self: flex-start;
padding: 1px 7px; border-radius: 999px;
font-family: var(--app-mono); font-size: 0.75rem; font-weight: 700;
color: var(--app-text);
background: color-mix(in srgb, var(--block) 20%, var(--app-surface));
border: 1px solid color-mix(in srgb, var(--block) 48%, transparent);
white-space: nowrap;
}
.cell-block::before { content: ""; width: 6px; height: 6px; border-radius: 2px; background: var(--block); }
.cal-cell > .cell-block:last-child { margin-top: auto; margin-bottom: 2px; }
.cell-top { display: flex; align-items: center; justify-content: space-between; gap: 4px; min-width: 0; } .cell-top { display: flex; align-items: center; justify-content: space-between; gap: 4px; min-width: 0; }
.cell-badges { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; } .cell-badges { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; }
.dnum { font-family: var(--app-mono); font-size: 0.8125rem; font-weight: 700; line-height: 1.4; color: var(--app-text); } .dnum { font-family: var(--app-mono); font-size: 0.8125rem; font-weight: 700; line-height: 1.4; color: var(--app-text); }
@@ -836,22 +823,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.cell-count span { font-size: 0.75rem; color: var(--app-text-3); white-space: nowrap; } .cell-count span { font-size: 0.75rem; color: var(--app-text-3); white-space: nowrap; }
.cal-cell.out .cell-count b { color: var(--app-text-3); } .cal-cell.out .cell-count b { color: var(--app-text-3); }
.asg-strip {
position: absolute; left: 0; top: 0; bottom: 0;
display: flex; flex-direction: column;
width: 4px; overflow: hidden; border-radius: 2px 0 0 2px; z-index: 1;
}
.asg-strip i { display: block; flex: 1 1 0; min-height: 3px; background: var(--app-text-3); }
.asg-strip.sm { width: 3px; }
.season-band {
position: absolute; left: 0; right: 0; bottom: 0;
height: 4px; background: var(--sc, var(--app-accent)); opacity: 0.6;
pointer-events: none;
}
.season-band.start { border-top-left-radius: 3px; border-bottom-left-radius: 3px; left: 3px; }
.season-band.end { border-top-right-radius: 3px; border-bottom-right-radius: 3px; right: 3px; }
.pkg-tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; padding-bottom: 10px; border-bottom: 1px solid var(--app-border); } .pkg-tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; padding-bottom: 10px; border-bottom: 1px solid var(--app-border); }
.pkg-tabs a { .pkg-tabs a {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
@@ -926,29 +897,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
} }
.quick-hint { margin: 0 0 10px !important; font-size: 0.6875rem; line-height: 1.45; } .quick-hint { margin: 0 0 10px !important; font-size: 0.6875rem; line-height: 1.45; }
/* 触控小键盘:3 列 4 行,底行 ⌫ 0 ✓ */
.keypad { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 10px 0 12px; }
.keypad .key {
height: 46px; padding: 0;
border: 1px solid var(--app-border); border-radius: 10px;
background: var(--app-surface); color: var(--app-text);
font-family: var(--app-mono); font-size: 1.0625rem; font-weight: 600;
cursor: pointer; user-select: none;
transition: background 0.1s ease, border-color 0.1s ease, transform 0.06s ease;
-webkit-tap-highlight-color: transparent;
}
.keypad .key:hover { border-color: var(--app-accent); color: var(--app-accent); }
.keypad .key:active { transform: translateY(1px); background: var(--app-accent-soft); }
.keypad .key-fn { font-size: 0.9375rem; color: var(--app-text-2); background: var(--app-surface-2); }
.keypad .key-ok { background: var(--app-accent-solid); border-color: var(--app-accent-solid); color: #fff; font-size: 1.125rem; }
.keypad .key-ok:hover { background: var(--app-accent-solid-hover); border-color: var(--app-accent-solid-hover); color: #fff; }
.keypad-toggle { min-width: 44px; }
.keypad-toggle.on {
background: var(--app-accent-soft);
border-color: color-mix(in srgb, var(--app-accent) 40%, transparent);
color: var(--app-accent-ink);
}
.picker { margin: 8px 0 12px; padding: 10px; border: 1px solid var(--app-accent); border-radius: var(--app-radius-sm); background: var(--app-surface-2); } .picker { margin: 8px 0 12px; padding: 10px; border: 1px solid var(--app-accent); border-radius: var(--app-radius-sm); background: var(--app-surface-2); }
.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } .picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.picker-head h4 { font-size: 0.8125rem; } .picker-head h4 { font-size: 0.8125rem; }
@@ -970,7 +918,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.task { .task {
display: flex !important; align-items: flex-start; gap: 10px; flex-wrap: nowrap; display: flex !important; align-items: flex-start; gap: 10px; flex-wrap: nowrap;
padding: 9px 0; border-bottom: 1px dashed var(--app-border); padding: 9px 0; border-bottom: 1px dashed var(--app-border);
border-left: 3px solid var(--asg, transparent); border-left: 3px solid transparent;
padding-left: 8px; margin-left: -3px; padding-left: 8px; margin-left: -3px;
} }
.task:last-child { border-bottom: 0; } .task:last-child { border-bottom: 0; }
@@ -1213,8 +1161,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.cell-load { display: none; } .cell-load { display: none; }
.cell-count b { font-size: 1rem; } .cell-count b { font-size: 1rem; }
.cell-count span { font-size: 0.625rem; } .cell-count span { font-size: 0.625rem; }
.cell-block { font-size: 0.5625rem; padding: 1px 3px; gap: 2px; }
.cell-block::before { width: 4px; height: 4px; }
.cal-cell { padding-left: 6px; } .cal-cell { padding-left: 6px; }
.cal-cell.today .dnum { width: 18px; height: 18px; } .cal-cell.today .dnum { width: 18px; height: 18px; }
.table { min-width: 0; } .table { min-width: 0; }
@@ -1232,9 +1178,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.table-adjust th:nth-child(5), .table-adjust td:nth-child(5), .table-adjust th:nth-child(5), .table-adjust td:nth-child(5),
.table-adjust th:nth-child(7), .table-adjust td:nth-child(7), .table-adjust th:nth-child(7), .table-adjust td:nth-child(7),
.table-adjust th:nth-child(8), .table-adjust td:nth-child(8) { display: none; } .table-adjust th:nth-child(8), .table-adjust td:nth-child(8) { display: none; }
.table-users th:nth-child(5), .table-users td:nth-child(5),
.table-users th:nth-child(6), .table-users td:nth-child(6),
.table-users th:nth-child(7), .table-users td:nth-child(7) { display: none; }
.table-wrap { -webkit-overflow-scrolling: touch; } .table-wrap { -webkit-overflow-scrolling: touch; }
.task { flex-wrap: wrap; } .task { flex-wrap: wrap; }
.task-actions { width: 100%; justify-content: flex-end; margin-left: 0; } .task-actions { width: 100%; justify-content: flex-end; margin-left: 0; }
@@ -1258,126 +1201,6 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
} }
/* ---------- 客户自助查询 /customer ---------- */
.portal-wrap { max-width: 980px; margin: 0 auto; padding: 24px 16px 56px; }
.portal-head { margin-bottom: 18px; }
.portal-brand {
display: inline-flex; align-items: center; gap: 8px;
margin-bottom: 12px; color: var(--app-text); text-decoration: none;
font-weight: 700; font-size: .9375rem;
}
.portal-brand:hover { color: var(--app-text); }
.portal-brand .brand-mark { width: 26px; height: 26px; font-size: .8125rem; }
.portal-head h1 { font-size: 1.6rem; font-weight: 800; margin: 0 0 4px; color: var(--app-text); }
.portal-head p { margin: 0; }
.portal-error { margin: 0 0 14px; }
.portal-search {
border: 1px solid var(--app-border); border-radius: var(--app-radius);
background: var(--app-surface); box-shadow: var(--app-shadow-sm);
overflow: hidden;
}
.portal-search-head {
display: flex; align-items: center; gap: 8px;
padding: 13px 16px; cursor: pointer; list-style: none;
font-weight: 600; color: var(--app-text);
}
.portal-search-head::-webkit-details-marker { display: none; }
.portal-search-head::after { content: "▾"; margin-left: auto; color: var(--app-text-3); font-size: .75rem; }
.portal-search[open] .portal-search-head { border-bottom: 1px solid var(--app-border); }
.portal-search[open] .portal-search-head::after { transform: rotate(180deg); }
.portal-form-body { padding: 14px 16px 16px; }
.portal-form-body .form-label { font-size: .8125rem; color: var(--app-text-2); margin-bottom: 5px; }
.portal-client { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
.portal-client-main { min-width: 0; }
.portal-client-name { font-size: 1.5rem; font-weight: 800; margin: 0 0 6px; }
.portal-client-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.portal-client-status { margin-left: auto; }
.portal-client-status .chip { font-size: .8125rem; }
.portal-section-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.portal-section-head h2 { font-size: 1rem; font-weight: 700; margin: 0; }
.portal-section-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
background: var(--app-surface-3); color: var(--app-text-2);
font-size: .75rem; font-weight: 600;
}
.portal-sub-list { margin-top: 2px; }
.portal-sub { padding: 12px 0; border-top: 1px solid var(--app-border); }
.portal-sub:first-child { border-top: 0; }
.portal-sub-main { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.portal-sub-main strong { font-size: .9375rem; }
.portal-sub-meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px;
margin-top: 4px; font-size: .8125rem; color: var(--app-text-2);
}
.portal-sub-meta .muted { color: var(--app-text-3); }
.portal-ask-names { font-size: .875rem; color: var(--app-text-2); }
.portal-ask-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.portal-ask-form .form-control { flex: 1 1 200px; width: auto; }
.pcal-nav { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.pcal-nav b { font-size: .875rem; min-width: 92px; text-align: center; }
.pcal-nav-btn {
width: 44px; height: 44px; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
font-size: 1.125rem; line-height: 1;
}
.pcal { margin-top: 10px; max-width: 880px; margin-left: auto; margin-right: auto; }
.pcal-dow { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); background: var(--app-surface-2); border-bottom: 1px solid var(--app-border); }
.pcal-dow span { padding: 6px 0; text-align: center; font-size: .72rem; color: var(--app-text-3); }
.pcal-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.pcal-cell {
position: relative;
display: flex; flex-direction: column; gap: 2px;
min-width: 0; min-height: 56px; padding: 6px 8px;
border-top: 1px solid var(--app-border); border-right: 1px solid var(--app-border);
background: var(--app-surface);
}
.pcal-cell:nth-child(7) { border-right: 0; }
.pcal-cell b { font-size: .8125rem; color: var(--app-text); }
.pcal-cell.out { background: var(--app-surface-2); }
.pcal-cell.out b, .pcal-cell.out span, .pcal-cell.out .pcal-count { color: var(--app-text-3); }
.pcal-cell.today { background: var(--app-accent-soft); box-shadow: inset 0 0 0 1.5px var(--app-accent); }
.pcal-today {
position: absolute; top: 5px; right: 6px;
font-style: normal; font-size: .5625rem; font-weight: 700;
color: var(--app-accent-ink); letter-spacing: 0;
}
.pcal-cell.has-block {
background: color-mix(in srgb, var(--block) var(--block-pct, 18%), var(--app-surface));
border-color: color-mix(in srgb, var(--block) 22%, var(--app-border));
}
.pcal-block {
display: inline-flex; align-items: center; gap: 4px; align-self: flex-start;
padding: 1px 6px; border-radius: 999px;
font-size: .6875rem; font-weight: 700; white-space: nowrap;
color: var(--app-text);
background: color-mix(in srgb, var(--block) 16%, var(--app-surface));
border: 1px solid color-mix(in srgb, var(--block) 28%, transparent);
}
.pcal-block::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--block); }
.pcal-dot { width: 7px; height: 7px; margin-top: auto; border-radius: 50%; border: 1.5px solid var(--app-accent); }
.pcal-dot.done { background: var(--app-accent); border-color: var(--app-accent); }
.pcal-count { margin-top: auto; font-size: .75rem; font-weight: 700; color: var(--app-accent); }
@media (max-width: 575px) {
.portal-wrap { padding: 16px 10px 40px; }
.portal-head h1 { font-size: 1.45rem; }
.portal-client-name { font-size: 1.3rem; }
.portal-client-status { margin-left: 0; }
.pcal-cell { min-height: 50px; padding: 4px 5px; }
.pcal-today { top: 3px; right: 4px; font-size: .5rem; }
.pcal-block { font-size: .625rem; padding: 1px 4px; }
.pcal-nav b { min-width: 74px; }
}
.tagchip, .pkg-tag { .tagchip, .pkg-tag {
display: inline-flex; align-items: center; gap: 4px; display: inline-flex; align-items: center; gap: 4px;
padding: 1px 8px; border-radius: 999px; padding: 1px 8px; border-radius: 999px;
+9 -217
View File
@@ -1,12 +1,10 @@
/* 代打台 · 交互增强(无框架,全部渐进增强) */ /* 代打台-L · 交互增强(无框架,全部渐进增强) */
/* Powered by Baka春秋 */
(function () { (function () {
'use strict'; 'use strict';
/* ---------- 工具:本地日期,禁止用 toISOString 做“自然日”计算 ---------- */ /* ---------- 工具:本地日期,禁止用 toISOString 做“自然日”计算 ---------- */
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function parseDay(s) { return new Date(s + 'T00:00:00'); } function parseDay(s) { return new Date(s + 'T00:00:00'); }
function fmtDay(d) { return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); }
function addDays(s, n) { var d = parseDay(s); d.setDate(d.getDate() + n); return fmtDay(d); }
function diffDays(a, b) { return Math.round((b.getTime() - a.getTime()) / 86400000); } function diffDays(a, b) { return Math.round((b.getTime() - a.getTime()) / 86400000); }
/* ---------- 提示条:自动淡出(.flash 固定定位,不会顶动页面) ---------- */ /* ---------- 提示条:自动淡出(.flash 固定定位,不会顶动页面) ---------- */
@@ -67,136 +65,18 @@
if (input.value) paint(input.value); if (input.value) paint(input.value);
}); });
/* ---------- 新订阅表单:按套餐类型切换「天数 / 次数 / 终止日」 ---------- */ /* ---------- 新订阅表单:选套餐时带出默认天数 ---------- */
document.querySelectorAll('form[data-sub-form]').forEach(function (form) { document.querySelectorAll('form[data-sub-form]').forEach(function (form) {
var sel = form.querySelector('[data-pkg-select]'); var sel = form.querySelector('[data-pkg-select]');
var daysWrap = form.querySelector('[data-days-wrap]'); var daysWrap = form.querySelector('[data-days-wrap]');
var countWrap = form.querySelector('[data-count-wrap]'); if (!sel || !daysWrap) return;
var endWrap = form.querySelector('[data-end-wrap]'); var daysInput = daysWrap.querySelector('input');
var startWrap = form.querySelector('[data-start-wrap]');
var startInput = form.querySelector('input[name="start_date"]');
var endInput = endWrap && endWrap.querySelector('input');
var cyclesWrap = form.querySelector('[data-cycles-wrap]');
var cyclesInput = cyclesWrap && cyclesWrap.querySelector('input');
if (!sel) return;
/* 只覆盖“上一次自动填的”终止日,不碰用户手填的值 */ sel.addEventListener('change', function () {
function setAutoEnd(v) {
if (!endInput || !v) return;
if (!endInput.value || endInput.dataset.auto === endInput.value) {
endInput.value = v;
endInput.dataset.auto = v;
}
}
/* 显示/隐藏一组字段;隐藏时一并 disable,避免同名字段重复提交 */
function setWrap(wrap, show) {
if (!wrap) return;
wrap.hidden = !show;
wrap.querySelectorAll('input, select, textarea').forEach(function (el) { el.disabled = !show; });
}
function sync() {
var opt = sel.options[sel.selectedIndex]; var opt = sel.options[sel.selectedIndex];
var unit = opt && opt.getAttribute('data-unit'); var amount = opt && parseInt(opt.getAttribute('data-amount') || '0', 10);
var amount = opt && opt.getAttribute('data-amount'); if (amount > 0 && daysInput) daysInput.value = amount;
var validity = opt && parseInt(opt.getAttribute('data-validity') || '0', 10);
if (unit === 'season') {
setWrap(daysWrap, false);
setWrap(countWrap, false);
setWrap(endWrap, false);
setWrap(startWrap, false);
setWrap(cyclesWrap, false);
if (startInput) startInput.required = false;
return;
}
setWrap(startWrap, true);
if (startInput) startInput.required = true;
if (unit === 'abyss') {
setWrap(daysWrap, false);
setWrap(countWrap, false);
setWrap(endWrap, false);
setWrap(cyclesWrap, true);
if (cyclesInput) {
cyclesInput.required = true;
if (amount) cyclesInput.value = amount;
}
return;
}
setWrap(cyclesWrap, false);
if (cyclesInput) cyclesInput.required = false;
if (unit === 'count') {
setWrap(daysWrap, false);
setWrap(countWrap, true);
setWrap(endWrap, true);
if (amount) countWrap.querySelector('input').value = amount;
if (validity > 0 && startInput && startInput.value) setAutoEnd(addDays(startInput.value, validity - 1));
} else if (unit === 'day') {
setWrap(daysWrap, true);
setWrap(countWrap, false);
setWrap(endWrap, true);
if (amount) daysWrap.querySelector('input').value = amount;
} else {
setWrap(daysWrap, true);
setWrap(countWrap, false);
setWrap(endWrap, false);
}
}
sel.addEventListener('change', sync);
if (startInput) {
startInput.addEventListener('change', function () {
var opt = sel.options[sel.selectedIndex];
var validity = opt && parseInt(opt.getAttribute('data-validity') || '0', 10);
if (opt && opt.getAttribute('data-unit') === 'count' && validity > 0 && startInput.value) {
setAutoEnd(addDays(startInput.value, validity - 1));
}
}); });
}
sync();
});
/* ---------- 套餐表单:切换计费方式与对应字段 ---------- */
document.querySelectorAll('form[data-package-form]').forEach(function (form) {
var label = form.querySelector('[data-unit-label]');
var amountTitle = form.querySelector('[data-amount-title]');
var radios = form.querySelectorAll('input[name="unit"]');
var seasonWrap = form.querySelector('[data-season-wrap]');
var validityWrap = form.querySelector('[data-validity-wrap]');
var abyssWrap = form.querySelector('[data-abyss-wrap]');
var amountWrap = form.querySelector('[data-amount-wrap]');
var cycleInput = form.querySelector('input[name="cycle_days"]');
function setWrap(wrap, show) {
if (!wrap) return;
wrap.hidden = !show;
wrap.querySelectorAll('input, select, textarea').forEach(function (el) { el.disabled = !show; });
}
function sync() {
var v = 'day';
radios.forEach(function (r) { if (r.checked) v = r.value; });
if (label) label.textContent = v === 'count' ? '次' : (v === 'abyss' ? '个' : '天');
if (amountTitle) amountTitle.textContent = v === 'abyss' ? '周期数' : '默认量';
setWrap(seasonWrap, v === 'season');
setWrap(validityWrap, v === 'count');
setWrap(abyssWrap, v === 'abyss');
setWrap(amountWrap, v !== 'season');
if (seasonWrap) {
seasonWrap.querySelectorAll('input').forEach(function (i) { i.required = v === 'season'; });
}
if (cycleInput) cycleInput.required = v === 'abyss';
}
radios.forEach(function (r) { r.addEventListener('change', sync); });
sync();
}); });
/* ---------- 批量开通:表头全选 + 半选状态 ---------- */ /* ---------- 批量开通:表头全选 + 半选状态 ---------- */
@@ -253,7 +133,7 @@
paint(); paint();
})(); })();
/* ---------- 触摸小键盘 + 抽屉时钟 + 更多菜单 ---------- */ /* ---------- 抽屉时钟 + 更多菜单 ---------- */
(function () { (function () {
'use strict'; 'use strict';
@@ -267,51 +147,6 @@
} }
if (clocks.length) { tick(); setInterval(tick, 1000); } if (clocks.length) { tick(); setInterval(tick, 1000); }
/* 小键盘:输入手机尾号
关键点:鼠标按下时阻止默认行为,否则按钮会先抢走输入框焦点;
每次按键后再把焦点和光标送回输入框末尾。 */
document.querySelectorAll('[data-keypad]').forEach(function (pad) {
// 键盘是表单的兄弟节点(不在 <form> 内),所以先找输入框,再用 input.form 反查表单
var scope = pad.closest('.drawer-block') || pad.parentElement || document;
var input = scope.querySelector('input[name="tail"]');
if (!input) { return; }
var form = input.form;
if (!form) { return; }
function keepFocus() {
try { input.focus({ preventScroll: true }); } catch (err) { input.focus(); }
var end = input.value.length;
try { input.setSelectionRange(end, end); } catch (err) { /* number 类型时忽略 */ }
}
// mousedown 会触发“按钮获得焦点”,先拦下来;pointerdown 再兜底一次
pad.addEventListener('mousedown', function (e) {
e.preventDefault();
keepFocus();
});
pad.addEventListener('pointerdown', function () {
// 这里不能 preventDefault:会连带取消后面的 click 事件
keepFocus();
});
pad.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-key]');
if (!btn) return;
var key = btn.getAttribute('data-key');
if (key === 'back') {
input.value = input.value.slice(0, -1);
} else if (key === 'ok') {
if (input.value) {
if (form.requestSubmit) { form.requestSubmit(); } else { form.submit(); }
}
return;
} else if (input.value.length < 8) {
input.value += key;
}
keepFocus();
});
});
/* 更多菜单:点开一个时收起其它,点空白处关闭 */ /* 更多菜单:点开一个时收起其它,点空白处关闭 */
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
document.querySelectorAll('.more-actions[open]').forEach(function (d) { document.querySelectorAll('.more-actions[open]').forEach(function (d) {
@@ -319,46 +154,3 @@
}); });
}); });
})(); })();
/* ---------- 触控/PC 小键盘模式 ---------- */
(function () {
'use strict';
var KEY = 'daili_keypad_mode';
function applyMode(touch) {
document.querySelectorAll('[data-keypad]').forEach(function (pad) {
pad.hidden = !touch;
var block = pad.closest('.drawer-block') || document;
var input = block.querySelector('input[name="tail"]');
if (input) {
input.readOnly = touch;
input.setAttribute('inputmode', touch ? 'none' : 'numeric');
input.setAttribute('placeholder', touch ? '点下方键盘输入尾号' : '输入手机尾号,回车即可完成');
}
var btn = block.querySelector('[data-keypad-toggle]');
if (btn) {
btn.textContent = touch ? '触控' : '⌨';
btn.classList.toggle('on', touch);
btn.title = touch ? '当前触控模式,点此切回 PC' : '当前 PC 模式,点此打开触控键盘';
}
});
}
var touchMode = false;
try { touchMode = localStorage.getItem(KEY) === 'touch'; } catch (err) { }
applyMode(touchMode);
document.querySelectorAll('[data-keypad-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
touchMode = !touchMode;
try { localStorage.setItem(KEY, touchMode ? 'touch' : 'pc'); } catch (err) { }
applyMode(touchMode);
if (!touchMode) {
var block = btn.closest('.drawer-block') || document;
var input = block.querySelector('input[name="tail"]');
if (input) { input.focus(); }
}
});
});
})();
-413
View File
@@ -1,413 +0,0 @@
/* ============================================================
代打台 · 可视化大屏(v1.10)
------------------------------------------------------------
只认 <html data-bs-theme="light|dark">;默认由模板给 dark。
Bootstrap 只用于重置与栅格,视觉全部在本文件内闭环。
============================================================ */
:root,
[data-bs-theme="light"] {
--scr-bg: #eef4fb;
--scr-panel: #ffffff;
--scr-panel-2: #f7fafd;
--scr-line: #dbe6f1;
--scr-text: #0f1c29;
--scr-text-2: #354a60;
--scr-muted: #5d7186;
--scr-accent: #2f5f9e;
--scr-accent-2: #3f74b4;
--scr-accent-solid: #2f5f9e;
--scr-danger: #b3261e;
--scr-warn: #8a5200;
--scr-glow: 0 0 18px rgba(47, 95, 158, 0.12);
--scr-shadow: 0 14px 32px -22px rgba(20, 32, 43, 0.4);
}
[data-bs-theme="dark"] {
--scr-bg: #070e16;
--scr-panel: #0f1823;
--scr-panel-2: #142030;
--scr-line: #203448;
--scr-text: #e7f0f9;
--scr-text-2: #b6c9db;
--scr-muted: #87a0b8;
--scr-accent: #6fa8dc;
--scr-accent-2: #93c4ee;
--scr-accent-solid: #2b5d94;
--scr-danger: #f08a82;
--scr-warn: #e0b072;
--scr-glow: 0 0 24px rgba(111, 168, 220, 0.16);
--scr-shadow: 0 18px 40px -24px rgba(0, 0, 0, 0.8);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body.screen-body {
background:
radial-gradient(1200px 600px at 12% -10%, color-mix(in srgb, var(--scr-accent) 14%, transparent), transparent 60%),
radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--scr-accent) 8%, transparent), transparent 55%),
var(--scr-bg);
color: var(--scr-text);
font-family: "Microsoft YaHei UI", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC",
system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
body.screen-body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
linear-gradient(var(--scr-line) 1px, transparent 1px) 0 0 / 100% 44px,
linear-gradient(90deg, var(--scr-line) 1px, transparent 1px) 0 0 / 44px 100%;
opacity: 0.35;
}
.scr {
position: relative; z-index: 1;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto auto auto;
gap: 10px;
height: 100vh;
height: 100dvh;
padding: 12px 16px 8px;
}
/* ---------- 头部 ---------- */
.scr-head { display: flex; align-items: center; gap: 14px; min-height: 40px; flex-wrap: wrap; padding-bottom: 8px; border-bottom: 1px solid var(--scr-line); }
.scr-brand { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; letter-spacing: 0.06em; color: var(--scr-text); }
.scr-brand .mark {
display: inline-grid; place-items: center;
width: 26px; height: 26px;
background: var(--scr-accent-solid); color: #fff;
font-size: 13px; font-weight: 800; border-radius: 8px;
}
.scr-brand em { font-style: normal; font-size: 11px; font-weight: 500; color: var(--scr-muted); letter-spacing: 0.16em; }
.scr-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
.scr-tabs a {
display: inline-flex; align-items: center; gap: 5px;
height: 28px; padding: 0 11px;
font-size: 12px; color: var(--scr-text-2); text-decoration: none;
border: 1px solid var(--scr-line); border-radius: 999px;
background: var(--scr-panel);
}
.scr-tabs a::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--tab, var(--scr-accent)); }
.scr-tabs a:hover { border-color: var(--scr-accent); color: var(--scr-text); text-decoration: none; }
.scr-tabs a.on { background: var(--scr-accent-solid); border-color: var(--scr-accent-solid); color: #fff; }
.scr-tabs a.on::before { background: #fff; }
.scr-tools { display: flex; align-items: center; gap: 8px; }
.scr-switch {
height: 28px; padding: 0 10px;
display: inline-flex; align-items: center;
font-size: 12px; color: var(--scr-accent); text-decoration: none;
border: 1px solid color-mix(in srgb, var(--scr-accent) 40%, var(--scr-line));
border-radius: 999px; background: var(--scr-panel);
}
.scr-switch:hover { background: color-mix(in srgb, var(--scr-accent) 12%, var(--scr-panel)); text-decoration: none; }
.scr-theme {
width: 28px; height: 28px; padding: 0;
display: inline-grid; place-items: center;
border: 1px solid var(--scr-line); border-radius: 999px;
background: var(--scr-panel); color: var(--scr-text-2);
font-size: 13px; cursor: pointer;
}
.scr-theme:hover { border-color: var(--scr-accent); color: var(--scr-accent); }
.scr-theme .ic-sun { display: none; }
[data-bs-theme="dark"] .scr-theme .ic-sun { display: inline; }
[data-bs-theme="dark"] .scr-theme .ic-moon { display: none; }
.scr-clock { margin-left: auto; text-align: right; line-height: 1.1; }
.scr-clock b { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 22px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; color: var(--scr-text); }
.scr-clock span { display: block; font-size: 11px; color: var(--scr-muted); }
.scr-links { font-size: 11px; color: var(--scr-muted); }
.scr-links a { color: var(--scr-accent); text-decoration: none; }
.scr-links a:hover { text-decoration: underline; }
/* ---------- KPI ---------- */
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.kpi {
position: relative;
display: flex; align-items: baseline; gap: 6px;
padding: 10px 14px 10px 18px;
background: var(--scr-panel);
border: 1px solid var(--scr-line);
border-radius: 10px;
overflow: hidden;
box-shadow: var(--scr-shadow);
}
.kpi::after {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--scr-accent);
}
.kpi span { font-size: 12px; color: var(--scr-muted); margin-right: auto; }
.kpi b { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 30px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; color: var(--scr-text); }
.kpi i { font-style: normal; font-size: 11px; color: var(--scr-muted); }
.kpi-hot::after { background: var(--scr-danger); }
.kpi-hot b { color: var(--scr-danger); }
.kpi-warm::after { background: var(--scr-warn); }
.kpi-warm b { color: var(--scr-warn); }
/* ---------- 主区 ---------- */
.scr-main { display: grid; grid-template-columns: 1.5fr 1fr; gap: 10px; min-height: 0; }
.panel {
display: flex; flex-direction: column; min-height: 0;
background: var(--scr-panel);
border: 1px solid var(--scr-line);
border-radius: 10px;
overflow: hidden;
box-shadow: var(--scr-shadow);
}
.panel-head {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--scr-line);
background: var(--scr-panel-2);
}
.panel-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 0.06em; color: var(--scr-text); }
.panel-head span { font-size: 11px; color: var(--scr-muted); }
/* 热力 */
.heat-grid { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; padding: 8px 12px; min-height: 0; min-width: 0; overflow: hidden; }
.heat-dow { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; margin-bottom: 4px; }
.heat-dow span { text-align: center; font-size: 10px; color: var(--scr-muted); }
.heat-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; margin-bottom: 4px; align-items: stretch; }
.heat-cell {
position: relative;
display: flex; align-items: center; justify-content: space-between;
min-height: 34px; min-width: 0;
padding: 3px 7px 3px 9px;
border: 1px solid var(--scr-line); border-radius: 6px;
background: var(--scr-panel-2);
font-size: 11px; color: var(--scr-muted);
overflow: hidden;
transition: transform 0.1s ease, border-color 0.1s ease;
}
.heat-cell span { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 11px; color: var(--scr-text-2); }
.heat-cell b { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 12px; font-weight: 700; color: var(--scr-text); white-space: nowrap; }
.heat-cell.out { background: var(--scr-panel-2); border-color: var(--scr-line); }
.heat-cell.out span, .heat-cell.out b { color: var(--scr-text-2); }
.heat-cell.past { opacity: 1; }
.heat-cell.today { outline: 1px solid var(--scr-accent); outline-offset: -1px; }
.heat-cell.today span { color: var(--scr-text); font-weight: 700; }
.heat-cell:hover { transform: translateY(-1px); border-color: var(--scr-accent); z-index: 3; }
.heat-cell.h1 { background: color-mix(in srgb, var(--scr-accent) 10%, var(--scr-panel-2)); }
.heat-cell.h2 { background: color-mix(in srgb, var(--scr-accent) 22%, var(--scr-panel-2)); }
.heat-cell.h3 { background: color-mix(in srgb, var(--scr-accent) 36%, var(--scr-panel-2)); }
.heat-cell.h4 { background: color-mix(in srgb, var(--scr-accent) 52%, var(--scr-panel-2)); }
.heat-cell.h3 span, .heat-cell.h4 span { color: var(--scr-text); }
.heat-cell.h3 b, .heat-cell.h4 b { color: var(--scr-text); }
.heat-cell.has-block {
background: color-mix(in srgb, var(--block) var(--block-pct, 18%), var(--scr-panel-2));
border-color: color-mix(in srgb, var(--block) 30%, var(--scr-line));
}
.heat-cell b.scr-block {
display: inline-flex; align-items: center;
padding: 1px 5px; border-radius: 4px;
background: color-mix(in srgb, var(--block) 20%, var(--scr-panel));
border: 1px solid color-mix(in srgb, var(--block) 32%, transparent);
color: var(--scr-text);
font-size: 12px; font-weight: 700;
}
.cell-urgent {
position: absolute; top: 3px; right: 3px;
width: 5px; height: 5px; border-radius: 50%;
background: var(--scr-danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--scr-danger) 70%, transparent);
}
.heat-legend { display: flex; align-items: center; gap: 4px; padding: 6px 14px 10px; font-size: 10px; color: var(--scr-muted); }
.heat-legend span { margin-right: 6px; }
.heat-legend i { width: 14px; height: 9px; border-radius: 2px; border: 1px solid var(--scr-line); display: inline-block; }
.heat-legend i.h0 { background: var(--scr-panel-2); }
.heat-legend i.h1 { background: color-mix(in srgb, var(--scr-accent) 10%, var(--scr-panel-2)); }
.heat-legend i.h2 { background: color-mix(in srgb, var(--scr-accent) 22%, var(--scr-panel-2)); }
.heat-legend i.h3 { background: color-mix(in srgb, var(--scr-accent) 36%, var(--scr-panel-2)); }
.heat-legend i.h4 { background: color-mix(in srgb, var(--scr-accent) 52%, var(--scr-panel-2)); }
.heat-legend .legend-hint { margin-left: auto; font-style: normal; color: var(--scr-text-2); }
/* TOP 10 */
.top-list { list-style: none; margin: 0; padding: 6px 10px; overflow: auto; flex: 1 1 auto; min-height: 0; }
.top-item {
display: grid;
grid-template-columns: 24px 56px minmax(0, 1fr) minmax(0, 118px) 78px 64px;
gap: 8px; align-items: center;
padding: 5px 4px;
border-bottom: 1px dashed var(--scr-line);
font-size: 12px;
}
.top-item:last-child { border-bottom: 0; }
.top-item .rank { grid-column: 1; font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 11px; color: var(--scr-muted); }
.top-item:nth-child(-n+3) .rank { color: var(--scr-accent); font-weight: 700; }
.top-tail { grid-column: 2; font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 14px; font-weight: 700; letter-spacing: 0.03em; color: var(--scr-text); }
.top-name { grid-column: 3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--scr-text-2); }
.top-pkg {
grid-column: 4;
font-size: 10px; padding: 1px 6px; border-radius: 999px;
border-left: 2px solid var(--tag, var(--scr-accent));
background: color-mix(in srgb, var(--tag, var(--scr-accent)) 14%, transparent);
color: var(--scr-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.top-remain { grid-column: 5; font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 11px; color: var(--scr-text-2); white-space: nowrap; }
.top-bar { grid-column: 6; height: 4px; background: var(--scr-line); border-radius: 999px; overflow: hidden; }
.top-bar i { display: block; height: 100%; background: var(--scr-accent); }
.top-item.lv-today .top-remain { color: var(--scr-danger); font-weight: 700; }
.top-item.lv-warn .top-remain { color: var(--scr-warn); }
.top-item.lv-today .top-bar i { background: var(--scr-danger); }
.top-item.lv-warn .top-bar i { background: var(--scr-warn); }
/* ---------- 底部两块 ---------- */
.scr-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; min-height: 168px; max-height: 30vh; }
.today-list { list-style: none; margin: 0; padding: 4px 10px 8px; overflow: auto; min-height: 0; }
.today-list li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 86px 52px 76px;
gap: 8px; align-items: center;
padding: 5px 6px;
border-bottom: 1px dashed var(--scr-line);
font-size: 12px;
}
.today-list li:last-child { border-bottom: 0; }
.today-list li.urgent {
background: color-mix(in srgb, var(--scr-danger) 8%, transparent);
border-left: 2px solid var(--scr-danger);
padding-left: 8px;
}
.today-list .tchip { grid-column: 1; }
.today-list .pkg { grid-column: 2; font-size: 10px; color: var(--scr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.today-list .asg { grid-column: 3; }
.today-list .tag-urgent { grid-column: 4; justify-self: start; }
.today-list .rem { grid-column: 5; font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 11px; color: var(--scr-text-2); white-space: nowrap; text-align: right; }
.tchip {
display: inline-flex; align-items: baseline; gap: 3px;
padding: 1px 6px;
border-left: 2px solid var(--tag, var(--scr-accent));
background: color-mix(in srgb, var(--tag, var(--scr-accent)) 14%, transparent);
border-radius: 2px 6px 6px 2px; white-space: nowrap;
color: var(--scr-text-2);
}
.tchip b { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 13px; color: var(--scr-text); }
.tag-urgent {
display: inline-flex; align-items: center;
height: 18px; padding: 0 6px;
font-size: 10px; font-weight: 700; line-height: 1;
background: color-mix(in srgb, var(--scr-danger) 18%, var(--scr-panel));
color: var(--scr-danger);
border: 1px solid color-mix(in srgb, var(--scr-danger) 32%, transparent);
border-radius: 999px;
white-space: nowrap;
}
.scr-empty, .scr-empty-inline { color: var(--scr-muted); font-size: 12px; padding: 16px; text-align: center; margin: 0; }
.scr-empty-inline { padding: 0; text-align: left; }
/* ---------- 最近追加 ---------- */
.scr-recent {
display: flex; align-items: center; gap: 10px;
padding: 5px 12px;
background: var(--scr-panel);
border: 1px solid var(--scr-line); border-radius: 10px;
font-size: 11px; color: var(--scr-muted);
overflow: hidden;
}
.recent-label { flex: 0 0 auto; letter-spacing: 0.12em; color: var(--scr-accent); font-size: 10px; }
.recent-track { display: flex; gap: 18px; overflow: hidden; white-space: nowrap; }
.recent-item { color: var(--scr-text-2); }
.recent-item b { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 12px; color: var(--scr-text); margin-right: 3px; }
.recent-item em { font-style: normal; color: var(--scr-accent); margin-left: 3px; }
.scr-foot { text-align: center; font-size: 10px; color: var(--scr-muted); letter-spacing: 0.06em; }
/* ---------- 当日明细浮层 ---------- */
.day-pop {
position: fixed; z-index: 60;
width: 340px; max-height: 62vh; overflow: auto;
padding: 10px 12px;
background: var(--scr-panel);
border: 1px solid var(--scr-accent);
border-radius: 10px;
box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.55), var(--scr-glow);
font-size: 12px;
pointer-events: auto;
overscroll-behavior: contain;
}
.day-pop[hidden] { display: none; }
.day-pop h4 { margin: 0 0 6px; display: flex; align-items: baseline; justify-content: space-between; font-size: 12px; color: var(--scr-text); }
.day-pop h4 small { font-weight: 400; color: var(--scr-muted); font-size: 10px; }
.day-pop .pop-meta { display: flex; gap: 10px; margin-bottom: 6px; font-size: 10px; color: var(--scr-muted); }
.day-pop .pop-meta b { color: var(--scr-text-2); font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; }
.day-pop ul { list-style: none; margin: 0; padding: 0; }
.day-pop li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 6px; align-items: center; padding: 3px 0; border-bottom: 1px dashed var(--scr-line); }
.day-pop li:last-child { border-bottom: 0; }
.day-pop li.urgent { color: var(--scr-danger); }
.day-pop li.done { opacity: 0.45; text-decoration: line-through; }
.day-pop .pop-tail { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 12px; font-weight: 700; }
.day-pop .pop-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--scr-text-2); }
.day-pop .pop-rem { font-size: 10px; color: var(--scr-muted); white-space: nowrap; }
.day-pop .pop-more { margin-top: 6px; font-size: 10px; color: var(--scr-muted); text-align: right; }
.day-pop .pop-empty { color: var(--scr-muted); text-align: center; padding: 8px 0; }
/* ---------- 打手颜色 ---------- */
.asg-strip {
position: absolute; left: 0; top: 0; bottom: 0;
display: flex; flex-direction: column;
width: 3px; overflow: hidden; border-radius: 6px 0 0 6px;
}
.asg-strip i { display: block; flex: 1 1 0; min-height: 3px; }
.asg-strip.sm { width: 3px; }
.asg { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--scr-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.asg i { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
.asg.none { color: var(--scr-muted); }
.asg.none i { opacity: 0.5; }
.pop-who { display: inline-block; width: 7px; height: 7px; border-radius: 2px; margin-right: 4px; vertical-align: 0; }
/* ---------- 小屏 / 投屏适配 ---------- */
@media (max-width: 1200px) {
.scr-main { grid-template-columns: 1fr 1fr; }
.scr-kpis .kpi b { font-size: 24px; }
.top-item { grid-template-columns: 24px 52px minmax(0, 1fr) 78px 56px; }
.top-item .top-pkg { display: none; }
.top-remain { grid-column: 4; }
.top-bar { grid-column: 5; }
.today-list li { grid-template-columns: auto minmax(0, 1fr) 52px 76px; }
.today-list .asg { display: none; }
.today-list .tag-urgent { grid-column: 3; }
.today-list .rem { grid-column: 4; }
}
@media (max-width: 860px) {
body.screen-body { overflow: auto; }
.scr { height: auto; }
.scr-main, .scr-bottom { grid-template-columns: 1fr; }
.scr-bottom { max-height: none; }
.scr-tabs, .scr-links { display: none; }
.scr-clock { margin-left: 0; }
.scr-kpis { grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.heat-cell { transition: none; }
}
-162
View File
@@ -1,162 +0,0 @@
/* 大屏:时钟 + 全屏 + 主题 + 日历悬停/点按明细 */
(function () {
'use strict';
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function tick() {
var d = new Date();
var clock = document.querySelector('[data-clock]');
if (clock) clock.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
var dateEl = document.querySelector('[data-date]');
if (dateEl) {
dateEl.textContent = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' 周' + '日一二三四五六'.charAt(d.getDay());
}
}
tick();
setInterval(tick, 1000);
document.addEventListener('keydown', function (e) {
if (e.key === 'f' || e.key === 'F') {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
}
}
if (e.key === 'Escape') { hide(); }
});
/* ---------- 主题切换:唯一状态源 data-bs-theme ---------- */
var THEME_KEY = 'daili_theme';
var root = document.documentElement;
var themeBtns = document.querySelectorAll('[data-theme-toggle]');
function currentTheme() {
return root.getAttribute('data-bs-theme') === 'light' ? 'light' : 'dark';
}
function paintTheme() {
var dark = currentTheme() === 'dark';
themeBtns.forEach(function (b) { b.setAttribute('aria-pressed', dark ? 'true' : 'false'); });
}
themeBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
var next = currentTheme() === 'dark' ? 'light' : 'dark';
root.setAttribute('data-bs-theme', next);
document.cookie = THEME_KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax';
paintTheme();
});
});
paintTheme();
/* ---------- 日历格子明细 ---------- */
var scr = document.querySelector('.scr');
var pop = document.getElementById('day-pop');
if (!scr || !pop) return;
var api = scr.getAttribute('data-api') || '/api/screen/day';
var pkg = scr.getAttribute('data-pkg') || '0';
var cache = {};
var hideTimer = null;
var pinned = false;
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function place(cell) {
var r = cell.getBoundingClientRect();
var w = 340, h = Math.min(360, pop.offsetHeight || 240);
var left = r.right + 8;
if (left + w > window.innerWidth - 8) left = Math.max(8, r.left - w - 8);
var top = r.top + r.height / 2 - h / 2;
top = Math.max(8, Math.min(top, window.innerHeight - h - 8));
pop.style.left = left + 'px';
pop.style.top = top + 'px';
}
function render(data, cell) {
if (!data) {
pop.innerHTML = '<div class="pop-empty">读取失败</div>';
return;
}
var d = new Date(data.date + 'T00:00:00');
var label = data.date + ' 周' + '日一二三四五六'.charAt(d.getDay());
var html = '<h4>' + esc(label) + '<small>' + data.total + ' 单 · 已完成 ' + data.done +
(data.urgent ? ' · <span style="color:var(--scr-danger)">加急 ' + data.urgent + '</span>' : '') + '</small></h4>';
if (!data.items || !data.items.length) {
html += '<div class="pop-empty">这天没有排单</div>';
} else {
html += '<ul>';
data.items.forEach(function (it) {
html += '<li class="' + (it.urgent ? 'urgent ' : '') + (it.done ? 'done' : '') + '">' +
'<span class="pop-tail">' + esc(it.tail) + '</span>' +
'<span class="pop-name"><i class="pop-who" style="background:' + esc(it.who_color || '#8A8F98') + '"></i>' + esc(it.who || '未指派') + ' · ' + esc(it.pkg) + '</span>' +
'<span class="pop-rem">' + (it.urgent ? '加急 · ' : '') + esc(it.remain) + '</span>' +
'</li>';
});
html += '</ul>';
if (data.more > 0) html += '<div class="pop-more">另有 ' + data.more + ' 单未显示</div>';
}
pop.innerHTML = html;
pop.hidden = false;
place(cell);
}
function show(cell) {
clearTimeout(hideTimer);
var date = cell.getAttribute('data-date');
var key = date + '|' + pkg;
pop.hidden = false;
pop.innerHTML = '<div class="pop-empty">载入中…</div>';
place(cell);
if (cache[key]) {
render(cache[key], cell);
return;
}
fetch(api + '?date=' + encodeURIComponent(date) + '&pkg=' + encodeURIComponent(pkg), {
headers: { 'Accept': 'application/json' }
}).then(function (r) {
if (!r.ok) throw new Error('bad');
return r.json();
}).then(function (data) {
cache[key] = data;
render(data, cell);
}).catch(function () {
pop.innerHTML = '<div class="pop-empty">读取失败</div>';
});
}
function hide() {
clearTimeout(hideTimer);
pinned = false;
pop.hidden = true;
}
function hideSoon() {
if (pinned) return;
clearTimeout(hideTimer);
hideTimer = setTimeout(function () { pop.hidden = true; }, 260);
}
function keepOpen() { clearTimeout(hideTimer); }
document.querySelectorAll('.heat-cell[data-date]').forEach(function (cell) {
cell.addEventListener('mouseenter', function () { show(cell); });
cell.addEventListener('mouseleave', hideSoon);
cell.addEventListener('focus', function () { show(cell); });
cell.addEventListener('click', function (e) {
e.preventDefault();
var openForCell = !pop.hidden && pop.dataset.date === cell.getAttribute('data-date');
if (openForCell) { hide(); return; }
pinned = true;
show(cell);
pop.dataset.date = cell.getAttribute('data-date');
});
});
pop.addEventListener('mouseenter', keepOpen);
pop.addEventListener('mouseleave', function () { if (!pinned) hideSoon(); });
})();
+6 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>{{.Title}} · 代打台</title> <title>{{.Title}} · 代打台-L</title>
<link rel="icon" href="/static/favicon.svg?v={{.AssetVersion}}" type="image/svg+xml"> <link rel="icon" href="/static/favicon.svg?v={{.AssetVersion}}" type="image/svg+xml">
<script> <script>
/* 首屏主题:服务端没有写 data-bs-theme 时(还没选过主题)跟随系统,避免闪白 */ /* 首屏主题:服务端没有写 data-bs-theme 时(还没选过主题)跟随系统,避免闪白 */
@@ -19,10 +19,12 @@
<link rel="stylesheet" href="/static/app.css?v={{.AssetVersion}}"> <link rel="stylesheet" href="/static/app.css?v={{.AssetVersion}}">
</head> </head>
<body> <body>
<!-- Powered by Baka春秋 · 代打台-L 盲水印(不可见,勿删) -->
<div class="wm-blind" aria-hidden="true" data-wm="Powered by Baka春秋" hidden-mark="Powered by Baka春秋">Powered by Baka春秋</div>
{{if .User}} {{if .User}}
<nav class="navbar navbar-expand-xl app-navbar sticky-top"> <nav class="navbar navbar-expand-xl app-navbar sticky-top">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="/calendar"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></a> <a class="navbar-brand" href="/calendar"><span class="brand-mark" aria-hidden="true">代</span><span>代打台-L</span></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="展开导航"><span class="navbar-toggler-icon"></span></button> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="展开导航"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="mainNav"> <div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
@@ -30,7 +32,6 @@
<li class="nav-item"><a class="nav-link{{if eq .Nav "customers"}} active{{end}}" href="/customers">客户</a></li> <li class="nav-item"><a class="nav-link{{if eq .Nav "customers"}} active{{end}}" href="/customers">客户</a></li>
<li class="nav-item"><a class="nav-link{{if eq .Nav "subscriptions"}} active{{end}}" href="/subscriptions">订阅</a></li> <li class="nav-item"><a class="nav-link{{if eq .Nav "subscriptions"}} active{{end}}" href="/subscriptions">订阅</a></li>
<li class="nav-item"><a class="nav-link{{if eq .Nav "packages"}} active{{end}}" href="/packages">套餐</a></li> <li class="nav-item"><a class="nav-link{{if eq .Nav "packages"}} active{{end}}" href="/packages">套餐</a></li>
<li class="nav-item"><a class="nav-link nav-screen" href="/screen" target="_blank" rel="noopener">大屏 ↗</a></li>
</ul> </ul>
<form class="nav-search" role="search" action="/customers" method="get"> <form class="nav-search" role="search" action="/customers" method="get">
<input class="form-control form-control-sm" type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" data-search> <input class="form-control form-control-sm" type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" data-search>
@@ -59,10 +60,10 @@
{{if .Error}}<div class="notice notice-danger" role="alert">{{.Error}}</div>{{end}} {{if .Error}}<div class="notice notice-danger" role="alert">{{.Error}}</div>{{end}}
<main class="{{if .User}}app-main{{else}}auth-main{{end}}">{{template "content" .}}</main> <main class="{{if .User}}app-main{{else}}auth-main{{end}}">{{template "content" .}}</main>
{{if .User}}<footer class="app-foot"> {{if .User}}<footer class="app-foot">
<span>代打台 <span class="foot-version">v{{.Version}}</span></span> <span>代打台-L <span class="foot-version">v{{.Version}}</span></span>
<span class="foot-by">Powered by Baka春秋</span>
<a href="/logs">操作日志</a> <a href="/logs">操作日志</a>
<a href="/settings">设置</a> <a href="/settings">设置</a>
<a href="/screen" target="_blank" rel="noopener">大屏</a>
</footer>{{end}} </footer>{{end}}
<script src="/static/vendor/bootstrap.bundle.min.js?v={{.AssetVersion}}" defer></script> <script src="/static/vendor/bootstrap.bundle.min.js?v={{.AssetVersion}}" defer></script>
<script src="/static/app.js?v={{.AssetVersion}}" defer></script> <script src="/static/app.js?v={{.AssetVersion}}" defer></script>
+2 -50
View File
@@ -41,29 +41,7 @@
<button class="btn btn-primary btn-sm">今日打卡</button> <button class="btn btn-primary btn-sm">今日打卡</button>
</form> </form>
{{end}} {{end}}
{{if .Abyss}} <a class="btn btn-soft btn-sm" href="?extend={{.ID}}#extend">追加时长</a>
{{if .CanAbyssToday}}
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{.Today}}">
<input type="hidden" name="status" value="done">
<button class="btn btn-primary btn-sm">完成本周期</button>
</form>
{{end}}
<span class="chip chip-active">已完成 {{.UsedCount}} / {{.TotalCount}} 周期</span>
{{else if .Season}}
{{if .CanPassToday}}
<form method="post" action="/subscriptions/{{.ID}}/season-done" data-confirm="标记后整段通行证完成,确认?">
<input type="hidden" name="date" value="{{.Today}}">
<button class="btn btn-primary btn-sm">完成通行证</button>
</form>
{{else if .SeasonDone}}
<span class="chip chip-active">通行证已完成 {{.SeasonDone}}</span>
{{else}}
<span class="chip chip-plain">不在通行证有效期</span>
{{end}}
{{else}}
<a class="btn btn-soft btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
{{end}}
{{if eq .Status "active"}} {{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?"> <form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
<button class="btn btn-soft btn-sm">暂停</button> <button class="btn btn-soft btn-sm">暂停</button>
@@ -93,12 +71,11 @@
<section class="extend" id="extend" style="--tag:{{.Sub.PackageColor}}"> <section class="extend" id="extend" style="--tag:{{.Sub.PackageColor}}">
<header class="extend-head"> <header class="extend-head">
<div> <div>
<h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加{{if .IsDay}}时长{{else}}次数{{end}}</h3> <h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加时长</h3>
<p>{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p> <p>{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p>
</div> </div>
<a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a> <a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a>
</header> </header>
{{if .IsDay}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form" <form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form"
data-extend data-base="{{.Base}}" data-old-end="{{.OldEnd}}" data-today="{{.Today}}"> data-extend data-base="{{.Base}}" data-old-end="{{.OldEnd}}" data-today="{{.Today}}">
<input type="hidden" name="back" value="{{.Back}}"> <input type="hidden" name="back" value="{{.Back}}">
@@ -132,30 +109,5 @@
<a class="btn btn-soft" href="{{.Back}}">取消</a> <a class="btn btn-soft" href="{{.Back}}">取消</a>
</div> </div>
</form> </form>
{{else}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form">
<input type="hidden" name="back" value="{{.Back}}">
<div class="extend-preview">
<div><span>当前剩余</span><b>{{.View.RemainText}}</b></div>
<div><span>已用 / 总数</span><b>{{.Sub.UsedCount}} / {{.Sub.TotalCount}}</b></div>
{{if .Sub.EndDate}}<div><span>有效期至</span><b>{{.Sub.EndDate}}</b></div>{{end}}
</div>
<div class="grid-2">
<label class="field"><span>追加次数</span>
<input type="number" name="add_count" min="1" max="10000" value="{{.DefaultAmount}}" required>
</label>
<label class="field"><span>新的有效期(留空不改)</span>
<input type="date" name="new_end_opt">
</label>
</div>
<label class="field"><span>备注</span>
<input type="text" name="note" maxlength="120" placeholder="例如:加购 10 次副本">
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn btn-soft" href="{{.Back}}">取消</a>
</div>
</form>
{{end}}
</section> </section>
{{end}} {{end}}
@@ -1,18 +0,0 @@
{{define "screenlayout"}}<!doctype html>
<html lang="zh-CN" data-bs-theme="{{if eq .Theme "light"}}light{{else}}dark{{end}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<meta http-equiv="refresh" content="{{.D.Refresh}}">
<title>{{.Title}} · 代打台</title>
<link rel="icon" href="/static/favicon.svg?v={{.D.AssetVersion}}" type="image/svg+xml">
<link rel="stylesheet" href="/static/vendor/bootstrap.min.css?v={{.D.AssetVersion}}">
<link rel="stylesheet" href="/static/screen.css?v={{.D.AssetVersion}}">
</head>
<body class="screen-body">
{{template "content" .}}
<script src="/static/vendor/bootstrap.bundle.min.js?v={{.D.AssetVersion}}" defer></script>
<script src="/static/screen.js?v={{.D.AssetVersion}}" defer></script>
</body>
</html>{{end}}
+6 -27
View File
@@ -40,9 +40,8 @@
{{range .D.Weeks}} {{range .D.Weeks}}
<div class="cal-week" role="row"> <div class="cal-week" role="row">
{{range .}} {{range .}}
<a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}{{if .BlockColor}} has-block{{end}}" <a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&{{$.D.FilterQuery}}" role="gridcell"{{if .BlockColor}} style="--block:{{.BlockColor}};--block-pct:{{.BlockPercent}}%"{{end}}> href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&{{$.D.FilterQuery}}" role="gridcell">
{{if .Strip}}<span class="asg-strip" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
<div class="cell-top"> <div class="cell-top">
<span class="dnum">{{.Day}}</span> <span class="dnum">{{.Day}}</span>
<span class="cell-badges"> <span class="cell-badges">
@@ -50,7 +49,6 @@
{{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}} {{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}}
</span> </span>
</div> </div>
{{if .BlockText}}<span class="cell-block" title="{{.BlockTitle}}">{{.BlockText}}</span>{{end}}
{{if .TaskCount}} {{if .TaskCount}}
<div class="cell-count"> <div class="cell-count">
<b>{{.DoneCount}}</b><span>/ {{.TaskCount}}</span> <b>{{.DoneCount}}</b><span>/ {{.TaskCount}}</span>
@@ -85,23 +83,8 @@
<input type="hidden" name="back" value="{{.D.Back}}"> <input type="hidden" name="back" value="{{.D.Back}}">
<input class="form-control form-control-sm" type="text" name="tail" placeholder="输入手机尾号,回车即可完成" maxlength="8" autocomplete="off" inputmode="numeric" aria-label="手机尾号快速完成"> <input class="form-control form-control-sm" type="text" name="tail" placeholder="输入手机尾号,回车即可完成" maxlength="8" autocomplete="off" inputmode="numeric" aria-label="手机尾号快速完成">
<button class="btn btn-sm btn-primary" type="submit">完成</button> <button class="btn btn-sm btn-primary" type="submit">完成</button>
<button type="button" class="btn btn-sm btn-ghost keypad-toggle" data-keypad-toggle title="切换触控/PC 模式" aria-label="切换触控/PC 模式">⌨</button>
</form> </form>
<p class="muted small quick-hint">唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。</p> <p class="muted small quick-hint">唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。</p>
<div class="keypad" data-keypad role="group" aria-label="数字小键盘" hidden>
<button type="button" class="key" data-key="1">1</button>
<button type="button" class="key" data-key="2">2</button>
<button type="button" class="key" data-key="3">3</button>
<button type="button" class="key" data-key="4">4</button>
<button type="button" class="key" data-key="5">5</button>
<button type="button" class="key" data-key="6">6</button>
<button type="button" class="key" data-key="7">7</button>
<button type="button" class="key" data-key="8">8</button>
<button type="button" class="key" data-key="9">9</button>
<button type="button" class="key key-fn" data-key="back" aria-label="退格">⌫</button>
<button type="button" class="key" data-key="0">0</button>
<button type="button" class="key key-ok" data-key="ok" aria-label="确认完成">✓</button>
</div>
{{if .D.PickTail}} {{if .D.PickTail}}
<section class="picker"> <section class="picker">
@@ -142,7 +125,7 @@
<ul class="list-group list-group-flush task-list"> <ul class="list-group list-group-flush task-list">
{{range .D.Tasks}} {{range .D.Tasks}}
{{if or .Done .Skipped}} {{if or .Done .Skipped}}
<li class="list-group-item task task-done px-0" style="--asg:{{.AssigneeHex}}"> <li class="list-group-item task task-done px-0">
<details class="done-fold"> <details class="done-fold">
<summary> <summary>
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span> <span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
@@ -153,7 +136,6 @@
</summary> </summary>
<div class="done-detail"> <div class="done-detail">
<div class="task-meta"> <div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span> <span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}} {{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div> </div>
@@ -173,7 +155,7 @@
<input type="hidden" name="back" value="{{$.D.Back}}"> <input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button> <button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
</form> </form>
{{if not (or .Season .Abyss)}}<a class="btn btn-soft btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加时长</a>{{end}} <a class="btn btn-soft btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加时长</a>
</div> </div>
</details> </details>
</div> </div>
@@ -181,7 +163,7 @@
</details> </details>
</li> </li>
{{else}} {{else}}
<li class="list-group-item task{{if .Urgent}} urgent{{end}} px-0" style="--asg:{{.AssigneeHex}}"> <li class="list-group-item task{{if .Urgent}} urgent{{end}} px-0">
<div class="task-main"> <div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}"> <a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span> <span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
@@ -189,7 +171,6 @@
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}} {{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
</a> </a>
<div class="task-meta"> <div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span> <span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}} {{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div> </div>
@@ -205,14 +186,12 @@
<details class="more-actions"> <details class="more-actions">
<summary class="btn btn-sm btn-ghost" title="更多操作" aria-label="更多操作">⋯</summary> <summary class="btn btn-sm btn-ghost" title="更多操作" aria-label="更多操作">⋯</summary>
<div class="more-menu"> <div class="more-menu">
{{if not .Season}}
<form method="post" action="/subscriptions/{{.ID}}/checkin"> <form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}"> <input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="skipped"> <input type="hidden" name="status" value="skipped">
<input type="hidden" name="back" value="{{$.D.Back}}"> <input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost" type="submit">跳过这次</button> <button class="btn btn-sm btn-ghost" type="submit">跳过这次</button>
</form> </form>
{{end}}
<form method="post" action="/tasks/urgent"> <form method="post" action="/tasks/urgent">
<input type="hidden" name="sub_id" value="{{.ID}}"> <input type="hidden" name="sub_id" value="{{.ID}}">
<input type="hidden" name="date" value="{{$.D.Selected}}"> <input type="hidden" name="date" value="{{$.D.Selected}}">
@@ -220,7 +199,7 @@
<input type="hidden" name="back" value="{{$.D.Back}}"> <input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button> <button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
</form> </form>
{{if not (or .Season .Abyss)}}<a class="btn btn-soft btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加时长</a>{{end}} <a class="btn btn-soft btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加时长</a>
</div> </div>
</details> </details>
</div> </div>
@@ -11,18 +11,11 @@
</div> </div>
</div> </div>
<div class="page-actions"> <div class="page-actions">
<form method="post" action="/customers/{{.D.Customer.ID}}/assignee" class="assign-form">
<select name="assignee_id" aria-label="归属打手">
<option value="0">未指派</option>
{{range .D.Users}}<option value="{{.ID}}"{{if eq $.D.Customer.AssigneeID .ID}} selected{{end}}>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Username}}{{end}}</option>{{end}}
</select>
<button class="btn btn-soft btn-sm" type="submit">保存打手</button>
</form>
<a class="btn btn-soft" href="/customers/{{.D.Customer.ID}}/edit">编辑资料</a> <a class="btn btn-soft" href="/customers/{{.D.Customer.ID}}/edit">编辑资料</a>
{{if .D.Customer.Archived}} {{if .D.Customer.Archived}}
<form method="post" action="/customers/{{.D.Customer.ID}}/restore"><button class="btn btn-soft" type="submit">恢复客户</button></form> <form method="post" action="/customers/{{.D.Customer.ID}}/restore"><button class="btn btn-soft" type="submit">恢复客户</button></form>
{{else}} {{else}}
<form method="post" action="/customers/{{.D.Customer.ID}}/archive" data-confirm="停用后该客户不再出现在日历和大屏,订阅会保留。确定停用?"><button class="btn btn-ghost" type="submit">停用客户</button></form> <form method="post" action="/customers/{{.D.Customer.ID}}/archive" data-confirm="停用后该客户不再出现在日历,订阅会保留。确定停用?"><button class="btn btn-ghost" type="submit">停用客户</button></form>
{{end}} {{end}}
<form method="post" action="/customers/{{.D.Customer.ID}}/delete" data-confirm="将永久删除该客户及其全部订阅、打卡与追加记录,无法恢复。确定删除?"> <form method="post" action="/customers/{{.D.Customer.ID}}/delete" data-confirm="将永久删除该客户及其全部订阅、打卡与追加记录,无法恢复。确定删除?">
<input type="hidden" name="confirm" value="delete"> <input type="hidden" name="confirm" value="delete">
@@ -35,9 +28,6 @@
{{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}} {{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}}
{{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}} {{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}}
{{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}} {{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}}
<div><dt>打手</dt><dd>
<span class="asg{{if eq .D.Customer.AssigneeID 0}} none{{end}}"><i style="background:{{if .D.Customer.AssigneeColor}}{{.D.Customer.AssigneeColor}}{{else}}#8A8F98{{end}}"></i>{{if .D.Customer.AssigneeName}}{{.D.Customer.AssigneeName}}{{else}}未指派{{end}}</span>
</dd></div>
<div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div> <div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div>
</dl> </dl>
</header> </header>
@@ -89,23 +79,17 @@
<select class="form-select form-select-sm" name="package_id" required data-pkg-select aria-label="选择套餐"> <select class="form-select form-select-sm" name="package_id" required data-pkg-select aria-label="选择套餐">
<option value="">选择套餐…</option> <option value="">选择套餐…</option>
{{range .D.Packages}} {{range .D.Packages}}
<option value="{{.ID}}" data-unit="{{if .Season}}season{{else if .Abyss}}abyss{{else}}{{.Unit}}{{end}}" data-amount="{{.DefaultAmount}}" data-validity="{{.DefaultValidityDays}}"{{if .Archived}} disabled{{end}}{{if .SeasonExpired $.TodayStr}} disabled{{end}}> <option value="{{.ID}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
{{.Name}} · {{.UnitLabel}} {{if .Season}}{{.SeasonStart}}~{{.SeasonEnd}}{{else if .Abyss}}{{.CycleDays}} 天/周期 × {{.DefaultAmount}} 周期{{else}}{{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{end}}{{if .SeasonExpired $.TodayStr}}(通行证已结束){{end}}{{if .Archived}}(已归档){{end}} {{.Name}} · {{.UnitLabel}} {{.DefaultAmount}} 天{{if .Archived}}(已归档){{end}}
</option> </option>
{{end}} {{end}}
</select> </select>
<label class="inline-field" data-start-wrap>开始日 <label class="inline-field" data-start-wrap>开始日
<input class="form-control form-control-sm" type="date" name="start_date" value="{{.TodayStr}}" required> <input class="form-control form-control-sm" type="date" name="start_date" value="{{.TodayStr}}" required>
</label> </label>
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}} <label class="inline-field" data-days-wrap>
天数 <input class="form-control form-control-sm" type="number" name="days" min="1" max="3650" value="30"> 天数 <input class="form-control form-control-sm" type="number" name="days" min="1" max="3650" value="30">
</label> </label>
<label class="inline-field" data-count-wrap hidden>
次数 <input class="form-control form-control-sm" type="number" name="total_count" min="1" max="10000" value="10">
</label>
<label class="inline-field" data-cycles-wrap hidden>
周期数 <input class="form-control form-control-sm" type="number" name="total_count" min="1" max="1000" value="4">
</label>
<label class="inline-field" data-end-wrap> <label class="inline-field" data-end-wrap>
终止日(可留空)<input class="form-control form-control-sm" type="date" name="end_date"> 终止日(可留空)<input class="form-control form-control-sm" type="date" name="end_date">
</label> </label>
@@ -163,9 +147,7 @@
<td class="mono small">{{if .BaseDate}}{{.BaseDate}}{{else}}—{{end}}</td> <td class="mono small">{{if .BaseDate}}{{.BaseDate}}{{else}}—{{end}}</td>
<td class="mono small">{{if .OldEndDate}}{{.OldEndDate}} → {{end}}{{if .NewEndDate}}{{.NewEndDate}}{{else}}—{{end}}</td> <td class="mono small">{{if .OldEndDate}}{{.OldEndDate}} → {{end}}{{if .NewEndDate}}{{.NewEndDate}}{{else}}—{{end}}</td>
<td class="mono"> <td class="mono">
{{if gt .DaysAdded 0}}<span class="plus">+{{.DaysAdded}} 天</span>{{end}} {{if gt .DaysAdded 0}}<span class="plus">+{{.DaysAdded}} 天</span>{{else}}—{{end}}
{{if gt .CountAdded 0}}<span class="plus">+{{.CountAdded}} 次</span>{{end}}
{{if and (le .DaysAdded 0) (le .CountAdded 0)}}—{{end}}
</td> </td>
<td class="small">{{if .Note}}{{.Note}}{{else}}—{{end}}</td> <td class="small">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
<td class="small">{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}</td> <td class="small">{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}</td>
@@ -29,24 +29,13 @@
<label class="field"><span>联系方式</span> <label class="field"><span>联系方式</span>
<input type="text" name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ"> <input type="text" name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ">
</label> </label>
<label class="field"><span>归属打手</span>
<select name="assignee_id">
<option value="0"{{if eq .D.Customer.AssigneeID 0}} selected{{end}}>未指派</option>
{{range .D.Users}}
<option value="{{.ID}}"{{if eq $.D.Customer.AssigneeID .ID}} selected{{end}}>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Username}}{{end}}</option>
{{end}}
</select>
<small>该客户之后的所有单子都记在这个打手名下</small>
</label>
</div>
<div class="grid-2">
<label class="field"><span>来源渠道</span> <label class="field"><span>来源渠道</span>
<input type="text" name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍"> <input type="text" name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
</label> </label>
</div>
<label class="field"><span>标签</span> <label class="field"><span>标签</span>
<input type="text" name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)"> <input type="text" name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)">
</label> </label>
</div>
<label class="field"><span>备注</span> <label class="field"><span>备注</span>
<textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea> <textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea>
</label> </label>
+3 -3
View File
@@ -2,9 +2,9 @@
<div class="auth"> <div class="auth">
<button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" aria-pressed="false"><span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span></button> <button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" aria-pressed="false"><span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span></button>
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></div> <div class="auth-brand"><span class="brand-mark" aria-hidden="true">代</span><span>代打台-L</span></div>
<h1>登录</h1> <h1>登录</h1>
<p>所有账户均为管理员,任何操作都会留痕。</p> <p>只有一个管理员账户,任何操作都会留痕。</p>
<form method="post" action="/login" class="stack"> <form method="post" action="/login" class="stack">
<input type="hidden" name="next" value="{{.Next}}"> <input type="hidden" name="next" value="{{.Next}}">
<label class="field"><span>用户名</span> <label class="field"><span>用户名</span>
@@ -15,7 +15,7 @@
</label> </label>
<button type="submit" class="btn btn-primary btn-block">登录</button> <button type="submit" class="btn btn-primary btn-block">登录</button>
</form> </form>
<p class="auth-foot">代打台 · 单文件部署 · 数据只在本机</p> <p class="auth-foot">代打台-L · 单文件部署 · 数据只在本机</p>
</div> </div>
</div> </div>
{{end}} {{end}}
@@ -4,7 +4,7 @@
<div> <div>
<h1>{{.D.Package.Name}}</h1> <h1>{{.D.Package.Name}}</h1>
<p> <p>
{{.D.Package.UnitLabel}} · {{.D.Package.AmountText}}{{if .D.Package.CycleLabel}} · {{.D.Package.CycleLabel}}{{end}} {{.D.Package.UnitLabel}} · {{.D.Package.AmountText}}
{{if .D.Package.Tag}} · 标签「{{.D.Package.Tag}}」{{end}}{{if .D.Package.CalendarSeparate}} · 独立日历{{end}} {{if .D.Package.Tag}} · 标签「{{.D.Package.Tag}}」{{end}}{{if .D.Package.CalendarSeparate}} · 独立日历{{end}}
{{if .D.Package.Archived}}<span class="chip chip-closed">已归档</span>{{end}} {{if .D.Package.Archived}}<span class="chip chip-closed">已归档</span>{{end}}
</p> </p>
@@ -26,21 +26,8 @@
<form method="post" action="/packages/{{.D.Package.ID}}/subscribe" class="section card"> <form method="post" action="/packages/{{.D.Package.ID}}/subscribe" class="section card">
<h2>批量开通</h2> <h2>批量开通</h2>
<p class="muted small">勾选客户后一次开通;已经是该套餐的客户会自动跳过。{{if .D.Package.Season}}通行证从套餐区间开始,整段完成。{{else if .D.Package.Abyss}}按周期数开通,结束日自动计算。{{else}}开始日期默认今天,数量默认取套餐设置,可改。{{end}}</p> <p class="muted small">勾选客户后一次开通;已经是该套餐的客户会自动跳过。开始日期默认今天,天数默认取套餐设置,可改。</p>
{{if .D.Package.Abyss}}
<div class="grid-2">
<label class="field"><span>开始日期</span>
<input type="date" name="start_date" value="{{.D.Today}}" required>
</label>
<label class="field"><span>周期数</span>
<input type="number" name="total_count" min="1" max="1000" value="{{.D.Package.DefaultAmount}}">
</label>
</div>
<p class="muted small">每 {{.D.Package.CycleDays}} 天一个周期,共 {{.D.Package.DefaultAmount}} 个周期;结束日按开始日自动计算。</p>
{{else if .D.Package.Season}}
<p class="muted small">通行证区间:{{.D.Package.SeasonStart}} → {{.D.Package.SeasonEnd}},开通后整段完成,不再按天排单。</p>
{{else if eq .D.Package.Unit "day"}}
<div class="grid-auto"> <div class="grid-auto">
<label class="field"><span>开始日期</span> <label class="field"><span>开始日期</span>
<input type="date" name="start_date" value="{{.D.Today}}" required> <input type="date" name="start_date" value="{{.D.Today}}" required>
@@ -49,19 +36,6 @@
<input type="number" name="days" min="1" max="3650" value="{{.D.Package.DefaultAmount}}"> <input type="number" name="days" min="1" max="3650" value="{{.D.Package.DefaultAmount}}">
</label> </label>
</div> </div>
{{else}}
<div class="grid-auto">
<label class="field"><span>开始日期</span>
<input type="date" name="start_date" value="{{.D.Today}}" required>
</label>
<label class="field"><span>次数</span>
<input type="number" name="total_count" min="1" max="10000" value="{{.D.Package.DefaultAmount}}">
</label>
<label class="field"><span>到期日(可选)</span>
<input type="date" name="end_date">
</label>
</div>
{{end}}
<div class="table-wrap"> <div class="table-wrap">
<table class="table tight"> <table class="table tight">
@@ -77,7 +51,7 @@
<tr{{if $subscribed}} class="archived"{{end}}> <tr{{if $subscribed}} class="archived"{{end}}>
<td>{{if $subscribed}}<span class="muted small" title="已是此套餐">—</span>{{else}}<input type="checkbox" name="customer_ids" value="{{.ID}}" aria-label="选择 {{.Name}}" checked>{{end}}</td> <td>{{if $subscribed}}<span class="muted small" title="已是此套餐">—</span>{{else}}<input type="checkbox" name="customer_ids" value="{{.ID}}" aria-label="选择 {{.Name}}" checked>{{end}}</td>
<td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td> <td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td>
<td>{{.Name}}{{if .AssigneeName}} <span class="asg"><i style="background:{{.AssigneeColor}}"></i>{{.AssigneeName}}</span>{{end}}</td> <td>{{.Name}}</td>
<td class="summary due-{{.Level}}">{{.Summary}}</td> <td class="summary due-{{.Level}}">{{.Summary}}</td>
<td>{{if $subscribed}}<span class="chip chip-closed">已是此套餐</span>{{else}}<span class="chip chip-plain">可开通</span>{{end}}</td> <td>{{if $subscribed}}<span class="chip chip-closed">已是此套餐</span>{{else}}<span class="chip chip-plain">可开通</span>{{end}}</td>
</tr> </tr>
+5 -35
View File
@@ -3,51 +3,21 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1> <h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1>
<p>按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数;通行证整段完成;深渊按周期完成。</p> <p>按日套餐用起止日期计算剩余天数;给客户开通时选择开始日与天数即可。</p>
</div> </div>
</header> </header>
<form method="post" class="form card" data-package-form <form method="post" class="form card" data-package-form
action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}"> action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}">
<label class="field"><span>套餐名称 <i>*</i></span> <label class="field"><span>套餐名称 <i>*</i></span>
<input type="text" name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 深渊通行证 / 10 次副本"> <input type="text" name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 深渊日常">
</label> </label>
<fieldset class="field">
<span>计费方式 <i>*</i></span>
<div class="radios">
<label><input type="radio" name="unit" value="day"{{if and (eq .D.Package.Unit "day") (not .D.Package.Season) (not .D.Package.Abyss)}} checked{{end}}>
<b>按日</b><small>有开始日与结束日,每天一条待打任务,可打卡</small></label>
<label><input type="radio" name="unit" value="count"{{if and (eq .D.Package.Unit "count") (not .D.Package.Season) (not .D.Package.Abyss)}} checked{{end}}>
<b>按次</b><small>记录剩余次数,排期完成时自动扣 1 次,有效期可选</small></label>
<label><input type="radio" name="unit" value="season"{{if .D.Package.Season}} checked{{end}}>
<b>通行证</b><small>通行证有效期内整段一次完成;日历上连续着色,每天显示同一个完成数</small></label>
<label><input type="radio" name="unit" value="abyss"{{if .D.Package.Abyss}} checked{{end}}>
<b>深渊</b><small>N 天一个周期,周期内任意一天完成即算该周期完成;按周期数计数</small></label>
</div>
</fieldset>
<div class="grid-2" data-season-wrap hidden>
<label class="field"><span>通行证开始日 <i>*</i></span><input type="date" name="season_start" value="{{.D.Package.SeasonStart}}"></label>
<label class="field"><span>通行证结束日 <i>*</i></span><input type="date" name="season_end" value="{{.D.Package.SeasonEnd}}"></label>
</div>
<div class="field field-narrow" data-abyss-wrap hidden>
<span>周期天数 N <i>*</i></span>
<input type="number" name="cycle_days" min="1" max="365" value="{{if .D.Package.Abyss}}{{.D.Package.CycleDays}}{{else}}7{{end}}" placeholder="例如 7">
<small>N 天一个周期;下方「周期数」填 X。开通后从开始日连续排 X 个周期。</small>
</div>
<div class="field field-narrow" data-validity-wrap hidden>
<span>按次默认有效期(天,0 = 不限)</span>
<input type="number" name="default_validity_days" min="0" max="3650" value="{{.D.Package.DefaultValidityDays}}">
</div>
<div class="grid-2"> <div class="grid-2">
<label class="field" data-amount-wrap><span><span data-amount-title>默认量</span> <i>*</i></span> <label class="field" data-amount-wrap><span><span data-amount-title>默认天数</span> <i>*</i></span>
<div class="input-suffix"> <div class="input-suffix">
<input type="number" name="default_amount" min="1" max="10000" value="{{.D.Package.DefaultAmount}}" required> <input type="number" name="default_amount" min="1" max="10000" value="{{.D.Package.DefaultAmount}}" required>
<b data-unit-label>{{if eq .D.Package.Unit "count"}}次{{else}}天{{end}}</b> <b data-unit-label>天</b>
</div> </div>
</label> </label>
<label class="field"><span>参考价(元,可空)</span> <label class="field"><span>参考价(元,可空)</span>
@@ -71,7 +41,7 @@
<label class="check-line"> <label class="check-line">
<input type="checkbox" name="calendar_separate" value="1"{{if .D.Package.CalendarSeparate}} checked{{end}}> <input type="checkbox" name="calendar_separate" value="1"{{if .D.Package.CalendarSeparate}} checked{{end}}>
<span>在日历中独立显示<small>通行证 / 深渊建议勾选:会单独开一个标签页,按色块连续展示,和其他日常任务分开看</small></span> <span>在日历中独立显示<small>勾选后会单独开一个标签页,和其他日常任务分开看</small></span>
</label> </label>
<label class="field"><span>说明</span> <label class="field"><span>说明</span>
-125
View File
@@ -1,125 +0,0 @@
{{define "content"}}
<div class="portal-wrap">
<header class="portal-head">
<a class="portal-brand" href="/customer"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></a>
<h1>排单查询</h1>
<p class="muted">输入手机尾号查看自己的排单与完成情况。</p>
</header>
{{if .InlineError}}<div class="notice notice-danger portal-error" id="portal-error" role="alert">{{.InlineError}}</div>{{end}}
<details class="portal-search" {{if or (not .Query) .InlineError}}open{{end}}>
<summary class="portal-search-head">{{if .InlineError}}修改查询{{else if .Query}}查询其他尾号{{else}}输入手机尾号查询{{end}}</summary>
<form class="portal-form" method="get" action="/customer">
<div class="portal-form-body">
<div class="row g-2 align-items-end">
<div class="col-12 col-sm-4">
<label class="form-label" for="tail">手机尾号(4–8 位)</label>
<input class="form-control" id="tail" name="tail" value="{{.Query}}" inputmode="numeric" pattern="[0-9]{4,8}" minlength="4" maxlength="8" placeholder="例如 2300" required{{if not .Query}} autofocus{{end}}{{if eq .InvalidField "tail"}} aria-invalid="true" aria-describedby="portal-error"{{end}}>
</div>
<div class="col-12 col-sm-4">
<label class="form-label" for="name">客户姓名(可选)</label>
<input class="form-control" id="name" name="name" value="{{.Name}}" maxlength="32" placeholder="尾号重复时填写"{{if eq .InvalidField "name"}} aria-invalid="true" aria-describedby="portal-error"{{end}}>
</div>
<div class="col-12 col-sm-4">
<button class="btn btn-primary w-100" type="submit">查询</button>
</div>
</div>
<p class="muted small mb-0 mt-2">只显示排单与完成情况,不含备注与价格。</p>
</div>
</form>
</details>
{{with .D}}
{{if .Masked}}
<section class="portal-result">
<div class="card mt-3"><div class="card-body">
<h2 class="h5">找到 {{.Count}} 位客户</h2>
<p class="muted mb-2">尾号 {{.Tail}} 对应多位客户,请补一个姓名后查询。</p>
{{if .Masked}}<p class="portal-ask-names">可能是:{{range $i, $n := .Masked}}{{if $i}}、{{end}}{{$n}}{{end}}</p>{{end}}
<form class="portal-ask-form" method="get" action="/customer">
<input type="hidden" name="tail" value="{{.Tail}}">
<label class="visually-hidden" for="ask-name">姓名</label>
<input class="form-control" id="ask-name" name="name" maxlength="32" placeholder="输入姓名(可只填一部分)" required autofocus>
<button class="btn btn-primary" type="submit">查询</button>
</form>
</div></div>
</section>
{{else}}
<section class="portal-result">
{{if .Subs}}
<div class="card mt-3"><div class="card-body">
<div class="portal-client">
<div class="portal-client-main">
<h2 class="portal-client-name">{{.Tail}} · {{.Name}}</h2>
<div class="portal-client-meta">
{{if .Game}}<span class="tagchip">{{.Game}}</span>{{end}}
{{if .Expiring}}<span class="tagchip">最近到期 {{.Expiring}}</span>{{end}}
</div>
</div>
<div class="portal-client-status">
{{if eq .TodayState "done"}}<span class="chip chip-active">今日已清完</span>
{{else if eq .TodayState "pending"}}<span class="chip chip-plain">今日待完成 {{.TodayPending}} / {{.TodayTotal}}</span>
{{else}}<span class="chip chip-plain">今日无排单</span>{{end}}
</div>
</div>
</div></div>
<div class="card mt-3"><div class="card-body">
<div class="portal-section-head"><h2>订阅</h2><span class="portal-section-count">{{.SubCount}}</span></div>
<div class="portal-sub-list">
{{range .Subs}}
<article class="portal-sub">
<div class="portal-sub-main">
<strong>{{.PackageName}}</strong>
<span class="tagchip">{{.UnitLabel}}</span>
{{if .TodayLabel}}<span class="chip chip-{{.TodayClass}}">{{.TodayLabel}}</span>{{else}}{{template "state-chip" .}}{{end}}
</div>
<div class="portal-sub-meta">
<span>{{.RemainText}}</span>
<span class="muted">{{.PeriodText}}</span>
</div>
</article>
{{end}}
</div>
</div></div>
<div class="card mt-3"><div class="card-body">
<div class="portal-section-head">
<h2>当月排单</h2>
<div class="pcal-nav">
<a class="btn btn-soft pcal-nav-btn" href="{{.Prev}}" aria-label="上个月">‹</a>
<b>{{.MonthTitle}}</b>
<a class="btn btn-soft pcal-nav-btn" href="{{.Next}}" aria-label="下个月">›</a>
</div>
</div>
<div class="pcal" role="grid" aria-label="{{.MonthTitle}}排单">
<div class="pcal-dow" role="row">
<span role="columnheader">一</span><span role="columnheader">二</span><span role="columnheader">三</span><span role="columnheader">四</span><span role="columnheader">五</span><span role="columnheader">六</span><span role="columnheader">日</span>
</div>
{{range .Weeks}}
<div class="pcal-row" role="row">
{{range .}}
<div class="pcal-cell{{if not .InMonth}} out{{end}}{{if .Today}} today{{end}}{{if .BlockColor}} has-block{{end}}" role="gridcell" aria-label="{{.Label}}"{{if .BlockColor}} style="--block:{{.BlockColor}};--block-pct:{{.BlockPercent}}%"{{end}}>
<b>{{.Day}}</b>
{{if .Today}}<i class="pcal-today">今天</i>{{end}}
{{if .BlockText}}<span class="pcal-block" title="{{.BlockTitle}}">{{.BlockText}}</span>{{end}}
{{if .Total}}{{if eq .Total 1}}<span class="pcal-dot{{if .Done}} done{{end}}" title="{{.Done}}/{{.Total}} 单"></span>{{else}}<span class="pcal-count">{{.Done}}/{{.Total}}</span>{{end}}{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
</div></div>
{{else}}
<div class="card mt-3"><div class="card-body">
<h2 class="h4 mb-1">{{.Tail}} · {{.Name}}</h2>
<div class="portal-client-meta">{{if .Game}}<span class="tagchip">{{.Game}}</span>{{end}}</div>
<p class="muted mb-0 mt-3">还没有进行中的订阅。如需开通,请联系你的代打。</p>
</div></div>
{{end}}
</section>
{{end}}
{{end}}
</div>
{{end}}
-157
View File
@@ -1,157 +0,0 @@
{{define "content"}}
<div class="scr" data-pkg="{{.D.PkgID}}" data-heat="{{.D.HeatMode}}" data-api="/api/screen/day">
<header class="scr-head">
<div class="scr-brand"><span class="mark" aria-hidden="true">代</span>代打台<em>控制台</em></div>
{{if .D.HasTabs}}
<nav class="scr-tabs">
{{range .D.Tabs}}
<a class="{{if .Active}}on{{end}}" style="--tab:{{.Color}}" href="{{.Href}}">{{.Name}}</a>
{{end}}
</nav>
{{end}}
<div class="scr-tools">
<a class="scr-switch" href="/screen?{{.D.FilterQuery}}&heat={{if eq .D.HeatMode "due"}}task{{else}}due{{end}}"
title="切换热力统计口径">
{{if eq .D.HeatMode "due"}}热力:到期{{else}}热力:待完成{{end}} ⇄
</a>
<button class="scr-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" aria-pressed="false">
<span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span>
</button>
</div>
<div class="scr-clock"><b data-clock>--:--:--</b><span data-date></span></div>
<div class="scr-links">每 {{.D.Refresh}} 秒刷新 · <a href="/calendar">进入工作台</a></div>
</header>
<section class="scr-kpis">
<div class="kpi">
<span>客户</span><b>{{.D.Stats.Customers}}</b><i>位</i>
</div>
<div class="kpi">
<span>活跃订阅</span><b>{{.D.Stats.ActiveSubs}}</b><i>条</i>
</div>
<div class="kpi kpi-hot">
<span>今日到期</span><b>{{.D.Stats.DueToday}}</b><i>条</i>
</div>
<div class="kpi kpi-warm">
<span>7 天内到期</span><b>{{.D.Stats.Due7}}</b><i>条</i>
</div>
</section>
<section class="scr-main">
<div class="panel panel-cal">
<div class="panel-head">
<h2>{{if eq .D.HeatMode "due"}}到期热力{{else}}每日待完成{{end}}</h2>
<span>{{.D.MonthTitle}} · 本月合计 {{.D.HeatTotal}} 单</span>
</div>
<div class="heat-grid">
<div class="heat-dow">{{range .D.Weekdays}}<span>{{.}}</span>{{end}}</div>
{{range .D.Weeks}}
<div class="heat-row">
{{range .}}
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsPast}} past{{end}}{{if .BlockColor}} has-block{{end}}"
data-date="{{.Date}}" title="{{if .BlockTitle}}{{.BlockTitle}} · {{end}}{{.Date}}"{{if .BlockColor}} style="--block:{{.BlockColor}};--block-pct:{{.BlockPercent}}%"{{end}}>
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
<span>{{.Day}}</span>
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
{{if and .BlockText (not .Count)}}<b class="scr-block">{{.BlockText}}</b>{{else if .Count}}<b>{{.Done}}/{{.Count}}</b>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="heat-legend">
<span>{{if eq .D.HeatMode "due"}}到期数{{else}}待完成单量{{end}}</span>
<i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
<em class="legend-hint">悬停格子看当日明细</em>
</div>
</div>
<div class="panel panel-top">
<div class="panel-head"><h2>即将到期 TOP 10</h2><span>按剩余量排序</span></div>
{{if .D.Top}}
<ol class="top-list">
{{range $i, $s := .D.Top}}
<li class="top-item lv-{{$s.Eval.Level}}">
<span class="rank">{{add $i 1}}</span>
<span class="top-tail">{{$s.CustomerTail}}</span>
<span class="top-name">{{$s.CustomerName}}</span>
<span class="top-pkg" style="--tag:{{$s.PackageColor}}">{{$s.PackageName}}</span>
<span class="top-remain">{{$s.RemainShort}}</span>
<span class="top-bar"><i style="width:{{$s.Progress}}%"></i></span>
</li>
{{end}}
</ol>
{{else}}
<p class="scr-empty">暂无订阅数据</p>
{{end}}
</div>
</section>
<section class="scr-bottom">
<div class="panel">
<div class="panel-head">
<h2>今日到期</h2>
<span>{{len .D.DueToday}} 条</span>
</div>
{{if .D.DueToday}}
<ul class="today-list">
{{range .D.DueToday}}
<li class="lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有到期</p>
{{end}}
</div>
<div class="panel">
<div class="panel-head">
<h2>今日待打</h2>
<span>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成{{if gt .D.TodayUrgent 0}} · 加急 {{.D.TodayUrgent}}{{end}}</span>
</div>
{{if .D.TodayList}}
<ul class="today-list">
{{range .D.TodayList}}
<li class="{{if .Urgent}}urgent {{end}}lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
<span class="rem">{{.RemainShort}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有待打任务</p>
{{end}}
</div>
</section>
<section class="scr-recent">
<span class="recent-label">最近追加</span>
{{if .D.Recent}}
<div class="recent-track">
{{range .D.Recent}}
<span class="recent-item"><b>{{.CustomerTail}}</b>{{.CustomerName}} · {{.PackageName}}
{{if gt .DaysAdded 0}}<em>+{{.DaysAdded}} 天</em>{{end}}
{{if gt .CountAdded 0}}<em>+{{.CountAdded}} 次</em>{{end}}
</span>
{{end}}
</div>
{{else}}
<span class="scr-empty-inline">还没有追加记录</span>
{{end}}
</section>
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 全屏</footer>
<div class="day-pop" id="day-pop" hidden></div>
</div>
{{end}}
+3 -57
View File
@@ -3,73 +3,21 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>设置</h1> <h1>设置</h1>
<p>预警天数、备份、账户与数据导出</p> <p>预警天数、备份与数据导出</p>
</div> </div>
</header> </header>
<section class="section card"> <section class="section card">
<h2>基础设置</h2> <h2>基础设置</h2>
<form method="post" action="/settings" class="form"> <form method="post" action="/settings" class="form">
<div class="grid-2"> <label class="field field-narrow"><span>到期预警(天)</span>
<label class="field"><span>到期预警(天)</span>
<input type="number" name="warn_days" min="0" max="60" value="{{.D.WarnDays}}"> <input type="number" name="warn_days" min="0" max="60" value="{{.D.WarnDays}}">
</label> </label>
<label class="field"><span>大屏自动刷新(秒)</span>
<input type="number" name="screen_refresh" min="5" max="600" value="{{.D.ScreenRefresh}}">
</label>
</div>
<p class="muted small">监听地址、端口、数据库、备份、日志、时区等部署参数在 <code>daili.ini</code> 里配置,改完重启生效。</p> <p class="muted small">监听地址、端口、数据库、备份、日志、时区等部署参数在 <code>daili.ini</code> 里配置,改完重启生效。</p>
<div class="actions"><button class="btn btn-primary" type="submit">保存设置</button></div> <div class="actions"><button class="btn btn-primary" type="submit">保存设置</button></div>
</form> </form>
</section> </section>
<section class="section card">
<h2>管理员</h2>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。每个管理员的颜色用于日历左侧竖条和待办标识。</p>
<p class="muted small">{{if eq .User.ID 1}}你是初始管理员,可以点「编辑」修改其他管理员的用户名、昵称和密码。{{else}}只有初始管理员(首个账号)可以修改其他管理员的资料。{{end}}</p>
<div class="table-wrap">
<table class="table tight table-users">
<thead><tr><th scope="col">用户名</th><th scope="col">显示名称</th><th scope="col">打手颜色</th><th scope="col">状态</th><th scope="col">创建时间</th><th scope="col">最近登录</th><th scope="col"><span class="visually-hidden">操作</span></th></tr></thead>
<tbody>
{{$me := .User}}
{{range .D.Users}}
<tr>
<td class="mono">{{.Username}}</td>
<td>{{.DisplayName}}</td>
<td>
<form method="post" action="/settings/users/{{.ID}}/color" class="color-form">
<input type="color" name="color" value="{{if .Color}}{{.Color}}{{else}}#8A8F98{{end}}" title="打手颜色" aria-label="打手颜色">
<button class="btn btn-soft btn-sm" type="submit">保存</button>
</form>
</td>
<td>{{if .IsActive}}<span class="chip chip-active">启用</span>{{else}}<span class="chip chip-closed">停用</span>{{end}}</td>
<td class="mono small">{{.CreatedAt}}</td>
<td class="mono small">{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}}</td>
<td class="actions-cell">
{{if eq .ID $me.ID}}
<span class="muted small">当前账户</span>
{{else}}
{{if eq $me.ID 1}}
<a class="btn btn-soft btn-sm" href="/settings/users/{{.ID}}/edit">编辑</a>
{{end}}
<form method="post" action="/settings/users/{{.ID}}/toggle">
<button class="btn btn-ghost btn-sm">{{if .IsActive}}停用{{else}}启用{{end}}</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<form method="post" action="/settings/users" class="inline-form">
<label class="inline-field">用户名 <input type="text" name="username" required minlength="3" maxlength="32" autocomplete="off"></label>
<label class="inline-field">显示名称 <input type="text" name="display_name" maxlength="32"></label>
<label class="inline-field">初始密码 <input type="password" name="password" required minlength="6" autocomplete="new-password"></label>
<button class="btn btn-primary" type="submit">新增管理员</button>
</form>
</section>
<section class="section card"> <section class="section card">
<h2>修改我的密码</h2> <h2>修改我的密码</h2>
<form method="post" action="/settings/password" class="form"> <form method="post" action="/settings/password" class="form">
@@ -88,9 +36,7 @@
<div><dt>配置文件</dt><dd class="mono small">{{.D.ConfigPath}} <span class="muted">(修改后重启生效)</span></dd></div> <div><dt>配置文件</dt><dd class="mono small">{{.D.ConfigPath}} <span class="muted">(修改后重启生效)</span></dd></div>
<div><dt>数据库</dt><dd class="mono small">{{.D.DBPath}}</dd></div> <div><dt>数据库</dt><dd class="mono small">{{.D.DBPath}}</dd></div>
<div><dt>备份</dt><dd class="mono small">{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}</dd></div> <div><dt>备份</dt><dd class="mono small">{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}</dd></div>
<div><dt>大屏</dt><dd class="mono small">/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}</dd></div> <div><dt>局域网地址</dt><dd class="mono small">http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}</dd></div>
<div><dt>客户自助</dt><dd class="mono small">/customer {{if .D.PortalPublic}}免登录{{else}}已关闭{{end}}</dd></div>
<div><dt>局域网地址</dt><dd class="mono small">http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}/screen</dd></div>
<div><dt>已运行</dt><dd class="mono">{{.D.Uptime}}</dd></div> <div><dt>已运行</dt><dd class="mono">{{.D.Uptime}}</dd></div>
<div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div> <div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div>
</dl> </dl>
+4 -5
View File
@@ -2,17 +2,16 @@
<div class="auth"> <div class="auth">
<button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" aria-pressed="false"><span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span></button> <button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" aria-pressed="false"><span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span></button>
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></div> <div class="auth-brand"><span class="brand-mark" aria-hidden="true">代</span><span>代打台-L</span></div>
<h1>创建第一个管理员</h1> <h1>创建管理员</h1>
<p>数据库里还没有账户。设置好账号后即可开始录入套餐与客户。</p> <p>数据库里还没有账户。创建后不能再添加其他管理员。</p>
<form method="post" action="/setup" class="stack"> <form method="post" action="/setup" class="stack">
<label class="field"><span>用户名</span> <label class="field"><span>用户名</span>
<input class="form-control" id="username" type="text" name="username" required minlength="3" maxlength="32" autocomplete="username" placeholder="建议用拼音或英文名"> <input class="form-control" id="username" type="text" name="username" required minlength="3" maxlength="32" autocomplete="username" placeholder="建议用拼音或英文名">
</label> </label>
<label class="field"><span>显示名称</span> <label class="field"><span>显示名称</span>
<input class="form-control" id="display_name" type="text" name="display_name" maxlength="32" placeholder="例如:老王(可留空)"> <input class="form-control" id="display_name" type="text" name="display_name" maxlength="32" placeholder="例如:老王(可留空)">
</label> </label> <label class="field"><span>密码(至少 6 位)</span>
<label class="field"><span>密码(至少 6 位)</span>
<input class="form-control" id="password" type="password" name="password" required minlength="6" autocomplete="new-password"> <input class="form-control" id="password" type="password" name="password" required minlength="6" autocomplete="new-password">
</label> </label>
<label class="field"><span>再输一次密码</span> <label class="field"><span>再输一次密码</span>
@@ -49,7 +49,7 @@
<td class="mono small">{{.PeriodText}}</td> <td class="mono small">{{.PeriodText}}</td>
<td class="actions-cell"> <td class="actions-cell">
{{if ne .Status "closed"}} {{if ne .Status "closed"}}
{{if not (or .Season .Abyss)}}<a class="btn btn-soft btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>{{end}} <a class="btn btn-soft btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>
{{if eq .Status "active"}} {{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-ghost btn-sm">暂停</button></form> <form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-ghost btn-sm">暂停</button></form>
{{else if eq .Status "paused"}} {{else if eq .Status "paused"}}
@@ -1,39 +0,0 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>编辑管理员</h1>
<p class="muted">修改 {{.D.Target.Username}} 的登录名、昵称,或重置密码。</p>
</div>
<div class="page-actions">
<a class="btn btn-ghost" href="/settings">返回设置</a>
</div>
</header>
<form method="post" action="/settings/users/{{.D.Target.ID}}/edit" class="form card">
<div class="grid-2">
<label class="field"><span>用户名 <i>*</i></span>
<input type="text" name="username" value="{{.D.Target.Username}}" required minlength="3" maxlength="32" autocomplete="off">
</label>
<label class="field"><span>显示名称</span>
<input type="text" name="display_name" value="{{.D.Target.DisplayName}}" maxlength="32" autocomplete="off">
</label>
</div>
<div class="grid-2">
<label class="field"><span>新密码</span>
<input type="password" name="password" minlength="6" autocomplete="new-password" placeholder="留空则不修改">
</label>
<label class="field"><span>再输一次</span>
<input type="password" name="password2" minlength="6" autocomplete="new-password" placeholder="与上面一致">
</label>
</div>
<p class="muted small">重置密码后该账户的登录会话会立即失效,需要用新密码重新登录。当前账户:{{.User.Username}}</p>
<div class="actions">
<button class="btn btn-primary" type="submit">保存修改</button>
<a class="btn btn-ghost" href="/settings">取消</a>
</div>
</form>
</div>
{{end}}
+5 -95
View File
@@ -29,30 +29,8 @@ type SubView struct {
Today string Today string
// CanCheckinToday 表示今天可以直接打卡(订阅在有效状态与区间内) // CanCheckinToday 表示今天可以直接打卡(订阅在有效状态与区间内)
CanCheckinToday bool CanCheckinToday bool
// CanPassToday / CanAbyssToday 整段型订阅今天能否完成(在有效区间内且未完成)
CanPassToday bool
CanAbyssToday bool
} }
// AssigneeLabel 返回打手显示名;未指派时给灰字提示。
func (v SubView) AssigneeLabel() string {
if v.AssigneeName == "" {
return "未指派"
}
return v.AssigneeName
}
// AssigneeHex 返回打手颜色;未指派用中性灰。
func (v SubView) AssigneeHex() string {
if v.AssigneeColor == "" {
return "#8A8F98"
}
return v.AssigneeColor
}
// AssigneeNone 是否未指派。
func (v SubView) AssigneeNone() bool { return v.AssigneeName == "" }
func stateLabel(state string) (string, string) { func stateLabel(state string) (string, string) {
switch state { switch state {
case domain.StateActive: case domain.StateActive:
@@ -63,8 +41,6 @@ func stateLabel(state string) (string, string) {
return "暂停中", "paused" return "暂停中", "paused"
case domain.StateExpired: case domain.StateExpired:
return "已到期", "expired" return "已到期", "expired"
case domain.StateUsedUp:
return "次数用尽", "usedup"
case domain.StateClosed: case domain.StateClosed:
return "已结束", "closed" return "已结束", "closed"
} }
@@ -79,30 +55,9 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
Eval: e, Eval: e,
StateLabel: label, StateLabel: label,
StateClass: class, StateClass: class,
UnitLabel: "按日",
Today: domain.Date(today), Today: domain.Date(today),
} }
if s.Season {
v.UnitLabel = "通行证"
v.PeriodText = s.StartDate + " → " + s.EndDate
v.Progress = 0
if s.SeasonDone != "" {
v.RemainText = "已完成 1 / 1"
v.RemainShort = "1/1"
v.Progress = 100
} else {
v.RemainText = "已完成 0 / 1"
v.RemainShort = "0/1"
}
} else if s.Abyss {
v.UnitLabel = "深渊"
v.PeriodText = s.StartDate + " → " + s.EndDate
v.RemainText = fmt.Sprintf("已完成 %d / %d 周期", s.UsedCount, s.TotalCount)
v.RemainShort = fmt.Sprintf("%d/%d 周期", s.UsedCount, s.TotalCount)
if s.TotalCount > 0 {
v.Progress = clampPct(s.UsedCount * 100 / s.TotalCount)
}
} else if s.Unit == domain.UnitDay {
v.UnitLabel = "按日"
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays) v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays) v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
v.PeriodText = s.StartDate + " → " + s.EndDate v.PeriodText = s.StartDate + " → " + s.EndDate
@@ -110,21 +65,6 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
if total > 0 { if total > 0 {
v.Progress = clampPct(e.RemainingDays * 100 / total) v.Progress = clampPct(e.RemainingDays * 100 / total)
} }
} else {
v.UnitLabel = "按次"
v.RemainText = fmt.Sprintf("已完成 %d / %d 次", s.UsedCount, s.TotalCount)
v.RemainShort = fmt.Sprintf("%d/%d 次", s.UsedCount, s.TotalCount)
v.PeriodText = s.StartDate + " 起"
if s.EndDate != "" {
v.PeriodText += " · 有效期至 " + s.EndDate
} else {
v.PeriodText += " · 无有效期"
}
if s.TotalCount > 0 {
v.Progress = clampPct(s.UsedCount * 100 / s.TotalCount)
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
}
}
switch e.Level { switch e.Level {
case domain.LevelToday: case domain.LevelToday:
@@ -135,10 +75,8 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
v.DueText = "明天到期" v.DueText = "明天到期"
case e.DaysLeft > 1: case e.DaysLeft > 1:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft) v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
case s.Unit == domain.UnitDay:
v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays)
default: default:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft) v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays)
} }
case domain.LevelExpired: case domain.LevelExpired:
if s.EndDate != "" { if s.EndDate != "" {
@@ -148,33 +86,10 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
} else { } else {
v.DueText = "已到期" v.DueText = "已到期"
} }
} else {
v.DueText = "次数已用尽"
} }
} }
// 整段型:完成后统一显示「已完成」,不再提示到期 // 今天能否打卡:进行中且在有效区间内
if s.Season && s.SeasonDone != "" { v.CanCheckinToday = e.State == domain.StateActive
v.StateLabel = "已完成"
v.StateClass = "active"
v.DueText = ""
}
if s.Abyss && s.TotalCount > 0 && s.UsedCount >= s.TotalCount {
v.StateLabel = "已完成"
v.StateClass = "active"
v.DueText = ""
}
// 今天能否打卡:进行中、非整段型,且(按日或有剩余次数)
v.CanCheckinToday = !s.IsBlock() && e.State == domain.StateActive &&
(s.Unit == domain.UnitDay || e.RemainingCount > 0)
startT := domain.ParseDateOr(s.StartDate, today)
endT := domain.ParseDateOr(s.EndDate, today)
inRange := !today.Before(startT) && (s.EndDate == "" || !today.After(endT))
if s.Season && s.SeasonDone == "" && inRange && e.State == domain.StateActive {
v.CanPassToday = true
}
if s.Abyss && s.TotalCount > 0 && s.UsedCount < s.TotalCount && inRange && e.State == domain.StateActive {
v.CanAbyssToday = true
}
return v return v
} }
@@ -198,11 +113,10 @@ func subViews(subs []store.Subscription, today time.Time, warnDays int) []SubVie
// ---------- 追加面板 ---------- // ---------- 追加面板 ----------
// ExtendPanel 是「追加时长/次数」面板数据。 // ExtendPanel 是「追加时长」面板数据。
type ExtendPanel struct { type ExtendPanel struct {
Sub store.Subscription Sub store.Subscription
View SubView View SubView
IsDay bool
Today string Today string
OldEnd string OldEnd string
Base string Base string
@@ -234,7 +148,6 @@ func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time
p := &ExtendPanel{ p := &ExtendPanel{
Sub: *s, Sub: *s,
View: newSubView(*s, today, warnDays), View: newSubView(*s, today, warnDays),
IsDay: s.Unit == domain.UnitDay,
Today: domain.Date(today), Today: domain.Date(today),
Back: back, Back: back,
DefaultAmount: pkg.DefaultAmount, DefaultAmount: pkg.DefaultAmount,
@@ -242,9 +155,6 @@ func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time
if p.DefaultAmount <= 0 { if p.DefaultAmount <= 0 {
p.DefaultAmount = 30 p.DefaultAmount = 30
} }
if s.Unit != domain.UnitDay {
return p
}
oldEnd := domain.ParseDateOr(s.EndDate, today) oldEnd := domain.ParseDateOr(s.EndDate, today)
plan := domain.PlanExtend(oldEnd, oldEnd, today) plan := domain.PlanExtend(oldEnd, oldEnd, today)
suggested := domain.AddDays(plan.Base, p.DefaultAmount-1) suggested := domain.AddDays(plan.Base, p.DefaultAmount-1)
+33 -17
View File
@@ -17,6 +17,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
@@ -26,7 +27,7 @@ import (
"daili/internal/web" "daili/internal/web"
) )
const version = "1.11.5" const version = "1.11.5-L"
func main() { func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")") cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
@@ -41,7 +42,7 @@ func main() {
flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true }) flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true })
log.SetFlags(log.LstdFlags) log.SetFlags(log.LstdFlags)
log.SetPrefix("[代打台] ") log.SetPrefix("[代打台-L] ")
exeDir := executableDir() exeDir := executableDir()
cfgPath := *cfgFlag cfgPath := *cfgFlag
@@ -51,7 +52,7 @@ func main() {
cfg, err := config.Load(cfgPath, exeDir) cfg, err := config.Load(cfgPath, exeDir)
if err != nil { if err != nil {
// 配置写错时不要吞掉,直接指向具体行 // 配置写错时不要吞掉,直接指向具体行
fmt.Fprintf(os.Stderr, "\n[代打台] 配置文件有问题,已停止启动:\n\n%v\n\n", err) fmt.Fprintf(os.Stderr, "\n[代打台-L] 配置文件有问题,已停止启动:\n\n%v\n\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -130,8 +131,6 @@ func main() {
BackupKeep: cfg.BackupKeep, BackupKeep: cfg.BackupKeep,
BackupDir: cfg.BackupDir, BackupDir: cfg.BackupDir,
BackupEnabled: cfg.BackupEnabled, BackupEnabled: cfg.BackupEnabled,
ScreenPublic: cfg.ScreenPublic,
PortalPublic: cfg.PortalPublic,
ConfigPath: cfg.Path, ConfigPath: cfg.Path,
Version: version, Version: version,
}) })
@@ -201,7 +200,7 @@ func main() {
} }
func fatal(format string, args ...any) { func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "\n[代打台] "+format+"\n\n", args...) fmt.Fprintf(os.Stderr, "\n[代打台-L] "+format+"\n\n", args...)
os.Exit(1) os.Exit(1)
} }
@@ -240,10 +239,11 @@ func executableDir() string {
func printBanner(cfg *config.Config, dbPath string) string { func printBanner(cfg *config.Config, dbPath string) string {
port := strconv.Itoa(cfg.ListenPort) port := strconv.Itoa(cfg.ListenPort)
const inner = 47
fmt.Println() fmt.Println()
fmt.Println(" ┌───────────────────────────────────────────────┐") fmt.Println(" ┌" + strings.Repeat("─", inner) + "┐")
fmt.Println(" │ 代 打 台 · 代打管理平台 │") fmt.Println(" " + bannerRow("代 打 台 - L · 代打管理平台", inner))
fmt.Println(" └───────────────────────────────────────────────┘") fmt.Println(" └" + strings.Repeat("─", inner) + "┘")
fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path)) fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path))
fmt.Printf(" 数据库 %s\n", dbPath) fmt.Printf(" 数据库 %s\n", dbPath)
fmt.Println(" 访问地址:") fmt.Println(" 访问地址:")
@@ -265,19 +265,35 @@ func printBanner(cfg *config.Config, dbPath string) string {
fmt.Printf(" 地址 %s\n", primary) fmt.Printf(" 地址 %s\n", primary)
} }
if cfg.ScreenPublic {
fmt.Printf(" 大屏 %s/screen(免登录)\n", primary)
} else {
fmt.Printf(" 大屏 %s/screen(需登录)\n", primary)
}
if cfg.PublicBind() && cfg.ScreenPublic {
fmt.Println(" 注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen")
}
fmt.Println(" 按 Ctrl+C 停止服务") fmt.Println(" 按 Ctrl+C 停止服务")
fmt.Println() fmt.Println()
return primary return primary
} }
// bannerRow 把一行标题居中放进方框;中文按两列宽计算,保证不歪。
func bannerRow(text string, inner int) string {
w := displayWidth(text)
if w >= inner {
return "│" + text + "│"
}
left := (inner - w) / 2
right := inner - w - left
return "│" + strings.Repeat(" ", left) + text + strings.Repeat(" ", right) + "│"
}
// displayWidth 估算终端显示宽度:非 ASCII 一律按两列算。
func displayWidth(s string) int {
w := 0
for _, r := range s {
if r < 0x1100 {
w++
continue
}
w += 2
}
return w
}
func openBrowser(url string) { func openBrowser(url string) {
var cmd *exec.Cmd var cmd *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
+126
View File
@@ -0,0 +1,126 @@
# lite 版冒烟:起一个隔离实例,把所有页面都渲染一遍(用 curl.exe,绕开受约束语言模式)
$ErrorActionPreference = 'Stop'
$root = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
if (-not (Test-Path (Join-Path $root 'go.mod'))) { $root = Join-Path $root 'subtrack' }
$port = 17779
$exe = Join-Path $root 'daili-lite-smoke.exe'
$db = Join-Path $root '.gotmp\smoke-lite.db'
$jar = Join-Path $root '.gotmp\cookies.txt'
$base = "http://127.0.0.1:$port"
$resp = @{}
$i = 0
$fail = 0
New-Item -ItemType Directory -Force -Path (Join-Path $root '.gotmp') | Out-Null
foreach ($f in @($db, $jar)) { if (Test-Path $f) { Remove-Item $f -Force } }
$env:GOFLAGS='-mod=mod'; $env:GOPROXY='off'; $env:GOSUMDB='off'
$env:GOCACHE=(Join-Path $root '.gocache'); $env:GOTMPDIR=(Join-Path $root '.gotmp')
Push-Location $root
go build -o $exe .
$buildOk = ($LASTEXITCODE -eq 0)
Pop-Location
if (-not $buildOk) { Write-Output 'BUILD FAILED'; exit 1 }
Write-Output 'build ok'
$logFile = Join-Path $root '.gotmp\smoke-server.log'
$proc = Start-Process -FilePath $exe -ArgumentList @('-addr', "127.0.0.1:$port", '-db', $db, '-no-open', '-no-backup') -PassThru -RedirectStandardOutput $logFile -RedirectStandardError ($logFile + '.err') -WindowStyle Hidden
Start-Sleep -Seconds 2
function Req($name, $path, $method = 'GET', $data = $null) {
$body = Join-Path $root ('.gotmp\resp-{0}.html' -f $global:i)
$global:i = $global:i + 1
$a = @('-s', '-o', $body, '-w', '%{http_code}', '-b', $global:jar, '-c', $global:jar, '-L', '--max-time', '20')
if ($method -eq 'POST') {
# 不要用 -X POST:curl 会在 303 重定向后继续 POST,导致下一跳 405
foreach ($k in $data.Keys) { $a = $a + @('--data-urlencode', ($k + '=' + $data[$k])) }
}
$a = $a + @($global:base + $path)
$code = (& curl.exe @a) -join ''
$text = ''
if (Test-Path $body) { $text = (Get-Content -Raw -Encoding utf8 $body) }
if ($null -eq $text) { $text = '' }
$bad = $text -match '页面渲染失败|模板不存在'
$status = if ($bad) { 'TEMPLATE-ERROR' } else { 'ok' }
if ($bad -or $code -ne '200') { $global:fail = $global:fail + 1 }
Write-Output ('{0,-26} {1} {2} len={3}' -f $name, $code, $status, $text.Length)
$global:resp[$name] = $text
}
function Post($name, $path, $data) { Req $name $path 'POST' $data }
Write-Output '--- 页面渲染 ---'
Req 'GET /login' '/login'
Req 'GET /setup' '/setup'
Post 'POST /setup' '/setup' @{ username = 'admin'; display_name = 'boss'; password = 'daili123'; password2 = 'daili123' }
Req 'GET /calendar' '/calendar'
Req 'GET /settings' '/settings'
Req 'GET /logs' '/logs'
Req 'GET /packages' '/packages'
Req 'GET /packages/new' '/packages/new'
Post 'POST /packages' '/packages' @{ name = 'monthly'; default_amount = '30'; price = '300'; color = '#3B6EA5'; sort_order = '0'; note = 'smoke' }
Req 'GET /packages/1' '/packages/1'
Req 'GET /packages/1/edit' '/packages/1/edit'
Req 'GET /customers' '/customers'
Req 'GET /customers/new' '/customers/new'
Post 'POST /customers' '/customers' @{ tail = '1234'; name = 'laozhang'; game_name = 'wzry'; game_server = 'wx'; contact = 'wx'; source = 'douyin'; tags = 'vip'; note = 'smoke' }
Req 'GET /customers/1' '/customers/1'
Req 'GET /customers/1/edit' '/customers/1/edit'
Post 'POST /sub-create' '/customers/1/subscriptions' @{ package_id = '1'; start_date = '2026-01-01'; days = '30'; end_date = ''; note = 'smoke' }
Req 'GET /subscriptions' '/subscriptions'
Req 'GET /calendar?extend' '/calendar?extend=1'
Req 'GET /calendar?pick' '/calendar?pick=1234'
Post 'POST /checkin' '/subscriptions/1/checkin' @{ date = '2026-01-01'; status = 'done'; note = '' }
Post 'POST /settings' '/settings' @{ warn_days = '5' }
Post 'POST /password' '/settings/password' @{ old_password = 'daili123'; password = 'daili456'; password2 = 'daili456' }
Write-Output '--- 真实数据(今天开通 → 日历应有待打任务)---'
$today = (Get-Date).ToString('yyyy-MM-dd')
Post 'POST /packages2' '/packages' @{ name = 'weekly'; default_amount = '7'; price = '80'; color = '#884422'; sort_order = '0' }
Post 'POST /sub2' '/customers/1/subscriptions' @{ package_id = '2'; start_date = $today; days = '30'; end_date = '' }
Req 'GET /calendar-live' '/calendar'
$live = $resp['GET /calendar-live']
Write-Output '--- 已移除的路由 ---'
foreach ($gone in @('/screen', '/customer', '/api/screen/day')) {
$code = (& curl.exe -s -o NUL -w '%{http_code}' -b $jar --max-time 10 ($base + $gone)) -join ''
$tag = if ($code -eq '404') { 'ok (404)' } else { 'UNEXPECTED'; }
if ($code -ne '404') { $fail = $fail + 1 }
Write-Output ('{0,-26} {1} {2}' -f ('GONE ' + $gone), $code, $tag)
}
Write-Output '--- 内容断言 ---'
$cal = $resp['GET /calendar']
function Assert($label, $haystack, $pattern, $expectFound) {
$found = $haystack -match $pattern
$pass = ($found -eq $expectFound)
if (-not $pass) { $global:fail = $global:fail + 1 }
$verdict = if ($pass) { 'ok' } else { 'FAIL' }
$what = if ($expectFound) { 'present' } else { 'absent' }
Write-Output ('{0,-34} {1} ({2} expected)' -f $label, $verdict, $what)
}
Assert '盲水印 Powered by Baka春秋' $cal 'Powered by Baka春秋' $true
Assert 'wm-blind 容器' $cal 'class="wm-blind"' $true
Assert '品牌 代打台-L' $cal '代打台-L' $true
Assert '页脚版本 v1.11.5-L' $cal 'v1\.11\.5-L' $true
Assert '大屏入口' $cal '大屏' $false
Assert '/screen 链接' $cal '/screen' $false
Assert '通行证' $cal '通行证' $false
Assert '深渊' $cal '深渊' $false
Assert '按次' $cal '按次' $false
Assert '小键盘' $cal '小键盘' $false
Assert '打手' $cal '打手' $false
Assert 'keypad 标记' $cal 'data-keypad' $false
Write-Output '--- 实时数据断言 ---'
Assert '日历有待打任务' $live '今日打卡|当日待完成' $true
Assert '剩余 30 天' $live '剩 30 天' $true
Assert '客户尾号 1234' $live '1234' $true
Assert '今天到期徽标' $live '今日到期' $true
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Write-Output '--- server log ---'
if (Test-Path $logFile) { Get-Content $logFile -Encoding utf8 | Select-Object -First 30 }
Write-Output ('SMOKE-FAILURES=' + $fail)