Compare commits

..
32 Commits
Author SHA1 Message Date
chun_qiu 0392f44cf1 fix(v1.11.5): 深渊日历按单周期显示 1/1 或 0/1,工作台与大屏同步
build / build-and-test (push) Successful in 3m26s
- 整段色块计数改为按当前格所在周期:cycleDone → 1/1,否则 0/1;
  不再把全局 used/total 重复到每一格。
- 抽出 blockCellText / blockCellPercent,工作台、大屏、客户自助页共用同一口径;
  通行证保持 0/1 / 1/1,深渊按所在周期 1/1 / 0/1。
- 大屏恢复通行证 / 深渊页签;screenCalSubs 补上缺失的 start(之前为零值,
  导致大屏周期判定和色阶都算错),大屏格子现在显示与工作台一致的 1/1、0/1
  以及 34%→4% 的周期色阶。
- 大屏色块样式:has-block / scr-block,亮暗两套都可读。
- 版本 1.11.5;README 的深渊说明同步,「工作台、大屏、客户自助页同一口径」。

验证:测试数据第一周期完成、第二周期未完成,工作台、 大屏、客户自助页均显示
20–26 为 1/1、27–31 为 0/1;周期浓度 34%/24%/14%/4%;gofmt / go vet /
go test 全绿。
2026-10-20 04:06:39 +08:00
chun_qiu 2e2d7d4bdd fix(v1.11.4): 深渊周期色块深浅跨度拉大
build / build-and-test (push) Successful in 3m19s
- abyssBlockPercent 从 22%→10% 改为 34%→4%;4 个周期为 34/24/14/4,
  相邻周期约差 10 个百分点,周期边界一眼可辨。
- 第一个周期明显更深,最后一个周期接近底色;主日历与客户自助页共用该函数,
  两处同步生效。
- 版本 1.11.4;README 的深渊说明同步为 34%→4%。
2026-10-20 03:51:15 +08:00
chun_qiu ddc7988f34 fix(v1.11.3): 客户自助查询 /customer 全量修复
build / build-and-test (push) Successful in 3m18s
- 路由:从 screen_public 拆出 portal_public(默认 true),关掉大屏不再连带 404;
  设置页显示客户自助状态;/customer 与 /screen 各自独立。
- 查询:尾号只接受 4–8 位数字,服务端精确匹配,去掉 LIKE 模糊回退;按 IP
  40 次/分钟限流;页面 noindex。
- 语义:修正「今日待完成」把已完成数当待完成数的问题;0/0 改为「今日无排单」;
  无订阅走空态;删掉进度条和重复的今日文案,只留一个状态。
- 订阅:复用管理端 SubView / calSub;通行证 1/1、深渊 x/y 周期;过期、暂停、
  按次用尽、跳过都有独立口径;「最近到期」只取未来日期。
- 日历:与主日历一致,只在本月格显示任务;通行证/深渊整段色块(深渊按周期
  减淡),日常任务用空心/实心点;每格带读屏描述;today 加「今天」文字标记。
- 错误:只渲染一次并放回内容容器,字段加 aria-invalid;删掉顶部重复横幅。
- 多客户:显示掩码姓名 + 卡内补姓名表单;姓名回填;翻月用 url.Values 编码;
  1 位尾号不触发查询;只有空态/补姓名时 autofocus。
- UI:品牌头、可折叠「查询其他尾号」、订阅全宽列表、状态统一用现有 chip、
  月份按钮 44px、去掉卡中卡 / opacity / 满屏 0/1。
- 版本 1.11.3;README 增加 4.4 与 portal_public 配置说明。

验证:gofmt / go vet / go test 全绿;portal_public=true 时 /customer 200、
false 时 404;screen_public=false + portal_public=true 时 /customer 200、
/screen 302;尾号 2 与 % 被拒;2222 多客户掩码;9900 日常点、3321 通行证
1/1、6677 深渊 1/4 + 已到期订阅;明暗 1440 与 390 截图无对比度/溢出问题。
2026-10-20 03:39:41 +08:00
subtrack 7371b86a97 fix(v1.11.2): 自助查询页对齐新主题变量并修正版本号
问题:
- 仓库后续提交已到 v1.11.1 并重建了 Bootstrap 主题层(--app-* 变量、淡蓝配色),自助查询页仍引用旧的 --ink-* 变量
- 结果:页面标题透明看不见、按钮回落成默认蓝、月历边框与底色失效
- 上一个提交把版本写成 1.10.0,实际版本号未变(仍是 1.11.1),版本号与功能对不上

修复:
- portal 相关样式全部迁移到 --app-* 变量(背景/边框/文字/强调色/柔和色)
- 标题改为实色文字,不再用渐变裁切,避免主题切换时不可见
- tagchip 补一套基于 --app-accent-soft 的样式
- 版本号 1.11.1 → 1.11.2(main.go 与 README 同步)

验证:
- 本地起服务实测 /customer 三种查询均 200
- 无头浏览器实拍 shots/portal-v1112b.png:标题、查询按钮、进度条、月历、今日高亮均正常
2026-10-20 03:17:40 +08:00
subtrack c84175b723 feat(v1.10.0): 新增客户自助查询页 /customer
需求与确认:
- 完全公开、不加入口链接(用户确认)
- 输入手机尾号查询;同一尾号命中多个客户时必须补姓名,模糊匹配(用户确认)
- 只展示排单与完成情况:月历每日 已完成/总数、订阅套餐名与类型、到期时间、今日是否清完;不显示备注、打手、价格(用户确认)

实现:
- 新增 internal/web/handlers_portal.go:handleCustomerPortal + buildPortal
  - 尾号查客户(CustomersByTail,已过滤归档客户)
  - 多命中时返回候选家数并要求补姓名,姓名模糊匹配(不区分大小写)
  - 月度网格:按订阅覆盖日期统计每日应打/已完成,打卡数据复用 CheckinsBetween
  - 今日汇总、最近到期日、活跃订阅数
- 新增模板 pages/portal.html(Bootstrap 卡片 + 自定义 .pcal 月历)
- 路由 GET /customer(公开,无需登录)
- app.css 增加门户与 pcal 样式
- 数据库零改动,未新增表或列

测试:
- HTTP 验证 6 条路径全部 200:落地页、仅尾号(提示补姓名)、尾号+姓名模糊、姓名不匹配、尾号不存在、翻月
- 无头浏览器实拍 shots/portal-v110.png(公开页)
- 已知待办:门户标题与主按钮的品牌色内联覆盖未生效,需下一版修正(Bootstrap 后加载覆盖了主题色)
2026-10-20 03:11:36 +08:00
chun_qiu 8d9cf658a3 fix(v1.11.1): 深渊周期色块逐周期减淡,方便一眼区分周期
build / build-and-test (push) Successful in 3m17s
- 深渊模式的整段色块增加按周期递减的底色浓度:第一个周期 22%,之后线性
  减淡,最后一个周期 10%(X 个周期时按 X 等分);同一周期内所有格子保持
  同一浓度,跨周期边界会出现一档明显的深浅变化。
- 通行证仍保持固定 18% 的自定义色淡化,不受影响。
- DayCell 新增 BlockPercent,日历格子通过 --block-pct 传给 color-mix;
  旧缓存页面没有该变量时回落到 18%。
- 版本号 1.11.0 → 1.11.1。

验证:4 周期深渊在日历上的浓度为 22/18/14/10%;截图实测第 1 周期底色
rgb(212,223,235)、第 2 周期 rgb(220,229,239),同周期内一致;gofmt / go vet /
go test 全绿。
2026-10-20 02:42:52 +08:00
chun_qiu bc5076ee41 feat(v1.11.0): 通行证/深渊模式、淡蓝配色与触控小键盘修复
- 触控小键盘补回 3×4 网格样式:上一版重写 CSS 时漏掉了 .keypad/.key,
  按钮退化成行内小方块;现在恢复 46px 圆角键、⌫/✓ 功能键和「触控」开关态。
- 通行证(原「包赛季」):整段有效期在独立日历上按套餐色连续着色(18% 透明度),
  每一格显示同一个「已完成 / 总单数」(0/1 或 1/1);完成后色块保留并显示 1/1。
  沿用 season_done 记录完成日期,交互动线不变。
- 新增深渊模式:套餐设置周期天数 N 与周期数 X;开通时从开始日排出 X 个 N 天周期,
  结束日 = 开始日 + N×X − 1。周期内任意一天完成即算该周期完成;同一周期重复完成
  不重复计数;撤销该周期最后一条完成打卡会回退计数。独立日历连续着色,每格显示
  同一个「已完成周期 / 总周期数」。
- 全量把「包赛季」改名为「通行证」;套餐表单新增通行证 / 深渊选项,深渊显示 N/X 字段;
  客户开通表单、套餐批量开通、订阅列表、客户订阅卡片、日历待办行同步适配,
  通行证 / 深渊不再显示「追加时长」(提交也会被服务端拒绝)。
- 快速完成 / 批量完成对通行证改为整段完成,对深渊走周期打卡,不再错误地按日写打卡。
- 配色改为淡蓝 / 暗蓝:亮色用暗蓝主色 + 淡蓝纸底,暗色用海军蓝底 + 淡蓝强调;
  默认套餐色改为 #3B6EA5;大屏同步蓝调并修正热力图例与今日数字的对比度。
- 大屏页签排除通行证 / 深渊(它们不计每日热力,避免点开是空日历);「今日待打」
  仍会列出待处理的通行证 / 深渊。
- 数据迁移:packages.abyss / packages.cycle_days / subscriptions.abyss /
  subscriptions.cycle_days,老库启动自动补列。
- 修复手机端整段色块把 7 列日历撑出屏幕:日历改成 minmax(0,1fr),小屏色块缩小。
- 版本号 1.10.0 → 1.11.0。

验证:gofmt / go vet / go test 全绿;亮暗两套、1440 与 390 两档截图走查;
通行证 0/1 → 1/1、深渊 1/4 → 2/4、同周期重复完成不重复计数、撤销回退均实测通过。
2026-10-20 02:33:57 +08:00
chun_qiu 8b9c347649 feat(v1.10.0): 重建 Bootstrap 主题层,修复迁移遗留的明暗/缓存/布局问题
build / build-and-test (push) Successful in 3m38s
背景:v1.9.0 把界面迁到 Bootstrap 5 时保留了旧自研 CSS,v1.9.2 又叠加了一层
「墨绿渐变」覆盖层,形成三套令牌(--paper/--ink、prefers-color-scheme、
data-bs-theme)并存。系统偏好与 Cookie 不一致时,页面会出现半亮半暗、表单
标签/抽屉标题/订阅页签不可见、输入框变黑块等问题。

本次改动:
- 样式加载顺序改为 bootstrap → app.css;删除旧纸墨令牌与 v1.9.2 覆盖层,
  app.css 重写为单一主题层(:root/[data-bs-theme=light] 与 [data-bs-theme=dark]
  两套语义令牌)。组件只读 --app-*,解决 --ink-2 同时被当作「次级文字色」和
  「卡片底色」的语义冲突。
- 主题只认 <html data-bs-theme>:服务端按 Cookie 直出;没有 Cookie 时由 <head>
  内联脚本在首屏按系统偏好补齐,修好「跟随系统」并避免闪白。大屏同理。
- 修复 Bootstrap 覆盖自研设计的问题:正文 14px + 中文字体栈、22/16/14px 标题、
  按钮/输入框/焦点环/卡片回到设计值;普通按钮新增 .btn-soft,不再变透明文字。
- 布局修复:客户/套餐表单的 .grid-2 不再塞 3 个字段;包详情页把筛选独立成
  GET 表单(在搜索框回车不再误触批量开通);客户详情操作区与 meta 重排;
  抽屉时钟改为固定 46px 并用它对齐抽屉头的 sticky 偏移;追加面板在抽屉里
  两列月历、不再 1100px 竖排;手机端日历数字不再换行溢出,移动端表格隐藏
  低优先级列,不再被裁切。
- 大屏:KPI/面板去掉 Bootstrap utility,统一由 screen.css 控制;TOP/今日待打
  改为固定列宽 + grid-column 对齐;浅色主题的链接、热力、图例对比度补齐;
  热力格不再整体降透明度;当日浮层可滚动并支持点击/键盘打开。
- 配色与可读性:普通文本对比度全部达标(截图 lint 由 65 处降到 0);日历
  外月日期改为浅底深字,不再整格 opacity;archived 行改为中性底色而非降透明。
- 缓存:新增内嵌静态资源内容指纹 AssetVersion(12 位 sha256),所有 /static
  链接带 ?v=;带指纹的响应 public, max-age=31536000, immutable,不带指纹的
  回源 no-cache;HTML 继续 no-store。改样式重新编译即自动破缓存,不再依赖
  手改版本号(仍保留 v1.10.0 作为产品版本)。
- 认证页重做:登录/初始化页改用 .auth 卡片(品牌、主题切换、绿色焦点环),
  去掉 min-height:100vh 叠加 main padding 造成的 40px 纵向滚动。
- JS:修 toISOString 计算自然日差一天的时区 bug;主题切换读 data-bs-theme
  并同步 aria-pressed;全选支持半选;大屏切主题同时更新 data-bs-theme;
  提示条改为固定定位、淡出不再顶动页面。
- README 更新主题与缓存说明;版本号 1.9.5 → 1.10.0。

验证:gofmt / go vet / go test 全绿;1440、1100、390 三档,明暗两套,工作台/
客户/套餐/订阅/设置/日志/大屏/登录全部截图走查,无横向溢出与控制台错误。
2026-10-20 01:46:11 +08:00
subtrack d76c5e3a54 fix(v1.9.5): 大屏浅色主题配色修复(标签/KPI/热力/TOP/待打)
build / build-and-test (push) Successful in 3m53s
- 大屏原为纯深色设计,浅色主题下沿用深色文字变量导致浅字贴白底
- 为浅色主题补齐 .scr 的 --bg/--surface/--line/--muted/--ink 变量,并显式覆盖:标签页、热力格日期与计数、KPI 标签与单位、TOP 榜与今日待打、页脚与说明
- 选中标签保持墨绿渐变白字,其它标签白底深字带描边
- 仍为 v1.9.5,仅前端样式
2026-10-20 01:08:14 +08:00
subtrack 3eb7d32fa9 fix(v1.9.5): 大屏热力日历格子错位与数字反色
- 根因:重构时 --ink-2 由「文字色」变成「背景色」,热力格数字取到深色背景色;同时 aspect-ratio + 内容宽度导致列宽不均、右侧被裁
- 热力网格强制 repeat(7, minmax(0,1fr)),单元格 width:100%、min-width:0、min-height:34px,去掉 aspect-ratio
- 格子内日期/计数/说明统一高对比文字,计数用亮绿加粗
- 非本月日期透明度从 .28 提到 .45,历史日期 .66→.75,避免整格发黑
- 仍为 v1.9.5,仅前端样式
2026-10-20 01:03:40 +08:00
subtrack a89018c19b fix(v1.9.5): 表格操作列不再撑高行、去掉行间空隙
- .actions 改 inline-flex、去换行、垂直居中,按钮统一内边距与行高
- 表格单元格统一上下内边距,操作列 nowrap,避免按钮撑出多余高度
- 仍为 v1.9.5,仅前端样式
2026-10-20 01:01:15 +08:00
subtrack 36e702977f fix(v1.9.5): 修复 asg/ticket/summary 文字在暗色变深、亮色变浅
- 根因:v1.9.2 重构时新增的 --ink-2 等色板变量与旧样式里的同名文字变量冲突,导致打手条、票签、订阅摘要继承到背景色当作文字色
- 为 .asg / .ticket / .summary 及其子元素显式指定文字色:亮色用 --ink-ink,暗色用 #dff5ea
- 到期状态单独取值:正常绿、临期橙、逾期红,暗色下同步提亮
- 版本 1.9.4 → 1.9.5;仅前端样式
2026-10-20 00:58:57 +08:00
subtrack 47b93b85c1 fix(v1.9.4): 大屏弱化文字提亮,可读性对比度达标
build / build-and-test (push) Successful in 3m58s
- 大屏弱化文字(控制台/日期/链接/说明/表头/TOP 副标题/热力日期)统一提亮到 4.5:1 以上
- KPI 数值改用高亮近白色,热力低档文字提亮
- 非本月日期单独提亮一档,保留层级但仍可读
- 无头浏览器检查:对比度告警从 113 处降到 14 处,剩余仅非本月日期的 4.27:1(刻意弱化,已提亮)
- 版本 1.9.3 → 1.9.4;仅前端样式
2026-10-20 00:12:40 +08:00
subtrack e0fcec2293 fix(v1.9.3): 修复任务卡按钮错位、日历头部对齐、标签深底深字
- 任务卡改为「正文 + 右侧操作」两列布局,按钮横排不换行;标题/副标题/备注分别用主文字色与弱化色
- 套餐/订阅行内操作按钮(详情、编辑、归档、完成、⋯)统一 flex 横排,不再竖排堆叠
- tagchip/chip 改浅底深字并区分明暗主题,修复深色背景配深色字体
- 日历头部(月份、‹ ›、回到今天、右侧统计)统一 align-items:center
- 徽标 text-bg-success 与表格表头、任务副标题对比度不足的问题按明暗主题分别补色
- 版本 1.9.3;仅前端样式

验证:无头浏览器两态截图(shots/ink193b/sheet.png),亮色主题已能正确切换
2026-10-19 23:42:40 +08:00
subtrack 2ca22043a7 feat(v1.9.2): 墨绿渐变 UI 重构(全站覆盖层),修复明暗主题切换
- 重做设计变量:墨绿主色 #16543F→#2FA37A 三段渐变,亮/暗两套 token 同时挂 data-theme 与 data-bs-theme
- 顶栏、导航选中态、按钮、卡片、表格、表单、日历格与热力、抽屉、小键盘、标签页、下拉、提示、滚动条全部改为墨绿渐变体系
- 修复暗色切换后部分控件仍用亮色变量:所有组件色值改为引用 --ink-* 变量,切换即时生效
- 大屏同步墨绿渐变背景与标签页
- 版本号 1.9.1 → 1.9.2;仅前端样式,数据库与业务逻辑零改动

注:本次截图脚本在无头 Chrome 启动阶段挂起,未取得实际运行截图,视觉验收待补。
2026-10-19 23:13:08 +08:00
subtrack fb6a84cb7e feat(v1.9.1): 自绘组件对齐 Bootstrap(键盘/日历格/热力),版本号 1.9.1
build / build-and-test (push) Successful in 5m18s
2026-10-19 22:34:16 +08:00
subtrack 621adfa4a8 feat(v1.9.0): Bootstrap 5 迁移第 3/4、4/4 轮(抽屉、表单、大屏内部)
版本号保持 1.9.0,仍为纯前端改动:
- 日历抽屉:快速输入改 form-control,任务/到期列表改 Bootstrap list-group,加急标签改 badge
- 客户详情:套餐下拉、开始日、天数/次数、终止日统一 form-select / form-control
- 大屏内部:KPI 改 card,TOP 榜与今日列表改 list-group,加急改 badge;热力网格保留自绘
- app.css 增加 Bootstrap 收敛规则(list-group 透明背景、任务分隔线)

验证:无头 Chrome 实拍日历、客户详情、大屏三页(shots/bs3-*.png),页面正常渲染
数据库与业务逻辑零改动
2026-10-19 22:17:01 +08:00
subtrack a83bfbb2b3 feat(v1.9.0): Bootstrap 5 迁移第 2/4 轮(登录/初始化页 + 基础组件)
本轮全部为前端改动,版本号保持 1.9.0:
- CSS 加载顺序调整为 app.css → bootstrap,让 Bootstrap 覆盖表格/按钮/表单/卡片等基础组件,日历/热屏等自定义组件类不受影响
- 主题切换同时写 data-theme 与 data-bs-theme,Bootstrap 组件跟随亮/暗
- 登录页、初始化页重写为 Bootstrap card + form-control + btn-primary,垂直居中
- 客户列表搜索框加 form-control

验证:
- 无头 Chrome 实拍登录、客户列表、套餐、设置四页(shots/bs2-*.png)
- 数据库与业务逻辑零改动

后续:第 3 轮日历抽屉/追加面板/客户月历,第 4 轮大屏内部与收尾
2026-10-19 22:15:39 +08:00
subtrack d955d0d7d2 feat(v1.9.0): 引入 Bootstrap 5(第 1/4 轮:框架与大屏头部)
本次开始把界面全量迁移到 Bootstrap 5 默认风格,分 4 轮:
- 第 1 轮(本次):Bootstrap 资源本地化 + 后台框架(导航/搜索/徽标/用户菜单/提示) + 大屏头部与面板容器
- 第 2 轮:登录/初始化页 + 客户/订阅/套餐/日志/设置页的表单与列表
- 第 3 轮:日历抽屉、快速完成、追加面板、客户月历、弹窗/下拉
- 第 4 轮:大屏内部(KPI/热力/TOP/今日列表/浮层)与收尾打磨

本轮改动(纯前端,无业务逻辑与数据库变更):
- 新增 internal/web/static/vendor/bootstrap.min.css、bootstrap.bundle.min.js(本地内嵌,离线可用)
- _layout.html 重写为 Bootstrap navbar + container-fluid:导航、搜索框、到期徽标、主题开关、用户下拉菜单、alert 提示
- 大屏布局引入 Bootstrap,html 使用 data-bs-theme 跟随亮/暗主题;头部改为 navbar,面板改为 card
- 版本号 1.8.2 → 1.9.0

验证:
- 无头 Chrome 实拍日历、大屏、登录三页(shots/bs1-*.png),Bootstrap 深色主题生效,导航与徽标样式正常
- 业务逻辑与数据库结构未改动
2026-10-19 22:14:19 +08:00
subtrack bb923aab4d fix(v1.8.2): 套餐计费方式切换时的显示/隐藏与默认值修复
build / build-and-test (push) Successful in 3m41s
按「any → 包时 / 按次 / 包赛季」规则检查后修复 3 处问题:

1) 包赛季套餐编辑时「按日」也被勾中
   - 赛季套餐在库里 unit='day' + season=1,模板只判断了 unit,导致按日/赛季两个 radio 都带 checked
   - 修正为「unit=day 且非赛季」才勾中按日

2) 客户开通表单:选中包赛季时仍显示且必填「开始日」
   - 赛季起止由套餐决定,开始日应隐藏
   - 表单加 data-start-wrap,JS 在赛季模式隐藏并取消 required,切回其它模式恢复

3) 按次开通时没有带出套餐默认有效期
   - 套餐选项新增 data-validity,选中按次且终止日为空时,自动按「开始日 + 默认有效期 - 1」带出到期日,仍可手改

现状(已逐项验证):
- 包时:默认量单位「天」,无有效期/赛季字段
- 按次:默认量单位「次」,显示「按次默认有效期(天)」,开单自动带出到期日
- 包赛季:显示赛季开始日/结束日(必填),隐藏默认量有效期与开始日;过期套餐下拉置灰

测试:
- 转换专项 10 项全过:三种模式的勾选状态、字段显示、按日→按次→赛季→按日 往返保存均正常
- 数据库未改动结构,往返保存后 unit/season/season_start/season_end 值正确
- 版本号 1.8.2
2026-10-19 19:57:12 +08:00
subtrack bcce6bb830 fix(v1.8.1): 按次显示已完成/总次数、隐藏已开通套餐、已结束订阅置灰置底
1) 按次计费显示口径
- 订阅卡片、列表、大屏 TOP 都从「剩 x / y 次」改为「已完成 x / y 次」
- 进度条改为按已用次数计算

2) 开通订阅下拉过滤
- 客户详情「开通订阅」下拉自动隐藏该客户已开通的套餐,延期只能走「追加」入口,避免新开叠加
- 若客户已开通全部套餐,下拉只剩占位项

3) 已结束订阅置灰置底
- 客户详情的订阅卡片按「进行中 → 已结束」排序,已结束排到最后
- 已结束卡片降低透明度、降低饱和度,鼠标悬停恢复部分可见

数据库:无变更
测试:
- 功能验证 5 项:按次文案、已开通套餐不在下拉、未开通仍可选、已结束卡片置灰、已结束排在最后
- 119 项验收回归全绿(8 条按次断言随之更新为新口径)
- 无头 Chrome 实拍客户详情页;版本号 1.8.1
2026-10-19 19:53:50 +08:00
subtrack a3fd817edc feat(v1.8): 包赛季模式、按次默认有效期、订阅删除
1) 包赛季(第三种计费方式)
- 套餐表单新增「包赛季」,套餐上直接填赛季开始日/结束日(必填,结束日不能早于开始日)
- 客户开通时自动带出赛季起止,不用再手填日期
- 日历上用横向色带标出整段赛季;赛季内任意一天点「完成」即整段完成
- 完成后不再进日历/待打,客户详情显示「赛季已完成 + 完成日期」
- 已过赛季结束日的套餐在客户开通下拉里置灰标「赛季已结束」,服务端同样拒绝开通

2) 按次默认有效期
- 套餐新增「按次默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可改

3) 订阅删除
- 已结束的订阅卡片在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加/加急记录

4) 客户月历、日历与大屏同步识别赛季(色带、完成状态)

数据库兼容性:
- 仅新增列:packages.season / default_validity_days / season_start / season_end,subscriptions.season / season_done
- 没有改动原有 unit 的 CHECK 约束、没有重建表;老库启动自动 ALTER,可重复迁移

测试:
- 功能验证 13 项:赛季套餐创建、过期套餐不可选、开通带出日期、日历色带、完成整段后隐藏、按次自动到期、删除订阅
- 119 项验收回归全绿;go test ./... 通过;无头 Chrome 实拍 3 张(日历色带 / 客户赛季卡片 / 套餐表单)
- 版本号 1.7.0 → 1.8.0
2026-10-19 19:26:47 +08:00
subtrack a2542e1135 feat(v1.7): 计数改已完成/总数、客户月历、套餐标签合并、批量开通、触控模式、按次到期
build / build-and-test (push) Successful in 3m36s
1) 日历计数口径
- 工作台与大屏的格子统一改为「已完成 / 总数」(如 2/5 = 共 5 单、做完 2 单)

2) 客户详情·当月排单
- 新增客户专属月历:每天显示 已完成/总数,可翻月,点某天跳转到日历对应日期
- 没有排单的日期淡化显示

3) 套餐标签与页签合并
- packages 新增 tag 列(可空),套餐编辑页可填
- 日历/大屏页签按标签合并:同标签套餐合成一个页签;勾了「独立显示」的套餐额外再开一个自己的页签
- 大屏同样支持标签筛选,链接参数为 tag=xxx

4) 批量开通
- 新增套餐详情页 /packages/{id}:显示套餐信息 + 客户列表
- 表头「全选」置顶,已订阅该套餐的客户置灰不可选;开始日期默认今天、天数/次数默认取套餐设置,均可改
- POST /packages/{id}/subscribe 批量开通,重复的自动跳过并汇总提示
- 修复 r.PostForm 未 ParseForm 导致拿不到勾选项的问题(批量打卡同源问题一并修正)

5) 触控 / PC 模式
- 尾号输入框右侧新增切换按钮,触控模式展开 3×4 键盘、输入框只读 + inputmode=none(不唤起系统输入法),按键直接写值
- 偏好记在 localStorage,默认 PC 模式(键盘收起)

6) 按次到期
- 按次订阅的到期日保持选填;填了到期后状态显示「已到期」且不再进日历待排(复用既有 end_date,无新列)

数据库兼容性:
- 只新增 packages.tag 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变

测试:
- 无头 Chrome 实拍 5 张页面(日历/触控模式/客户月历/套餐批量/大屏)
- 功能验证 18 项全过(计数口径、标签页签、标签筛选、批量开通与重复跳过、月历翻月、触控开关、按次过期不上日历)
- 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.7.0
2026-10-19 16:23:01 +08:00
subtrack fb771bc013 fix(v1.6.1): 小键盘点击不再抢走手机尾号输入框焦点
build / build-and-test (push) Successful in 3m17s
根因有两处:
1) 小键盘在 HTML 里是 <form> 的兄弟节点(不在表单内部),JS 用 pad.closest('form') 拿不到表单,
   于是整个按键逻辑直接 return —— 按键既不写入输入框,也没有任何反馈;
2) 原生按钮在 mousedown 时会获得焦点,把输入框的焦点顶掉。

修复:
- 改为先定位输入框,再用 input.form 反查表单,不再依赖 DOM 嵌套关系
- mousedown 阻止默认行为,按钮不再抢焦点;pointerdown / click 后统一把焦点与光标送回输入框末尾
- 未聚焦时点键盘会自动聚焦输入框;退格、确认逻辑不变

验证(真实 Chrome + CDP 真鼠标点击,不是单元模拟):
- F-01/02 点 1 后焦点仍在、值为 1
- F-03/04 连点 1/2/3 得到 123,焦点保持
- F-05/06 退格得到 12,焦点保持
- F-07/08 输入框失焦后点键盘,自动聚焦并写入
- 截图 shots/v161-keypad-focus.png
- 119 项验收回归全绿;版本号 1.6.1
2026-10-19 02:52:22 +08:00
subtrack 79a308b1b5 feat(v1.6): 键盘 3×4、格子计数排版、时钟修复、已完成折叠、静态资源破缓存
build / build-and-test (push) Successful in 3m28s
1) 触摸小键盘
- 改为 3 列 × 4 行,底行 ⌫ / 0 / ✓(数字 1-9 按 123/456/789 排列)
- 自定义按键样式:52px 高、圆角、按下位移反馈,确认键为主题绿,不再使用原生按钮外观

2) 日历格子
- 「待完成 / 总数」改为 34px 粗体、主题绿、靠左、垂直居中;左侧打手色竖条保留
- 仍不显示排单缩略信息

3) 抽屉时钟
- 修复不显示:根因是浏览器缓存了旧的 app.js(静态资源 max-age=300)
- 所有 CSS/JS 链接改为 /static/app.css?v=<版本号>,升级后自动取新文件
- 字号放大到 30px、水平垂直居中、24 小时制精确到秒,日期小字放在下方

4) 已完成排单折叠
- 当日清单里已完成/已跳过的单子折叠成一行:尾号 + 姓名 + 套餐 + 状态
- 点这一行展开,显示剩余、打手、撤销打卡与⋯菜单(加急/追加);未完成的行不变
- 折叠箭头改为 CSS 三角,展开/收起有过渡动画

5) 背景
- 管理端背景横线彻底移除(已用浏览器截图确认),大屏网格背景保留

验证:
- 无头 Chrome 实拍暗色/亮色两套日历页(shots/v16-dark.png、shots/v16-light.png)
- 界面断言 14 项全部通过;原有 119 项验收回归全绿;go vet / go test ./... 通过
- 版本号 1.6.0
2026-10-19 02:33:37 +08:00
subtrack 1f7d0f0cc4 feat(v1.5): 触摸键盘、格子大字计数、客户停用/删除、按钮收纳、7 天徽标、抽屉时钟
build / build-and-test (push) Successful in 3m19s
1) 工作台速录
- 手机尾号输入框下方新增 3×4 触摸小键盘(1-9、0、⌫、✓),点 ✓ 直接提交;实体键盘仍可输入
- 抽屉顶部新增秒级时钟(HH:MM:SS),位于日期标题上方

2) 日历格子
- 移除排单缩略信息(客户名/套餐色签/“x/y 单”小字)
- 保留左侧打手颜色竖条,中间改为大字「待完成 / 总数」,例如 1/2 = 还剩 1 单、共 2 单
- 大屏格子按此前约定保持原样

3) 客户停用 / 恢复 / 彻底删除
- customers 新增 archived 列(0/1);停用后默认从客户列表、日历、待办、大屏、到期提醒、徽标中隐藏,订阅与打卡记录原样保留
- 恢复后立即回到日历;列表右上角可切换「显示已停用」,已停用行带标记
- 彻底删除为二次确认操作,依赖外键级联清理订阅、打卡、暂停、追加记录与加急标记
- 停用/恢复/删除都会写入操作日志

4) 按钮收纳
- 任务行只露出「完成」,跳过 / 加急(取消加急)/ 追加收进「⋯」菜单;已完成行只露「撤销打卡」等,点空白处自动收起
- 加急、跳过等原有能力不变,仅入口折叠

5) 顶栏
- 在「今日到期」「3 天内到期」右侧新增「7 天内到期」徽标,固定统计未来 7 天

6) 视觉
- 移除管理端背景的横线装饰,改为纯底色;大屏网格背景保留

数据库兼容性:
- 只新增 customers.archived 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变

测试:
- 新增单测 TestCustomerArchiveFilters:停用后日历/到期/徽标/搜索/列表全部过滤、恢复回日历、彻底删除级联清理订阅与打卡
- 功能验证 20 项(键盘、格子计数、折叠菜单、7 天徽标、时钟、停用/恢复/删除、背景)全部通过
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.5.0
2026-10-19 02:12:25 +08:00
subtrack 95fa4fd6fd feat(v1.4): 初始管理员(id=1)可修改其他管理员的用户名、昵称和密码
build / build-and-test (push) Successful in 3m25s
- 设置 → 管理员:仅 id=1 的账户在“其他人”那一行看到「编辑」入口,自己那行不显示
- 新增编辑页 /settings/users/{id}/edit:可改用户名、显示名称,密码留空则不修改
- 重置密码后立即清除该账户的所有登录会话,强制对方用新密码重新登录;只改昵称/用户名不影响会话
- 服务端三重校验:非 id=1 拒绝、不能编辑自己、用户名 3-32 字符且唯一、密码 ≥6 位且两次一致
- 所有修改写入操作日志(旧用户名/昵称 → 新值,是否重置密码)
- 设置页根据当前账户显示不同提示:超管看到可编辑说明,其他人只看到“只有初始管理员可以修改”

数据库:无需变更(复用 users 表与 sessions 表)
测试:
- 新增单测 TestUpdateUserByAdmin:昵称更新、会话保留、改密后旧会话失效、新密码可登录旧密码失效、重名返回 ErrDuplicate、空用户名拒绝
- 功能验证 20 项:入口显示/隐藏、普通管理员与自我编辑被拒、改密踢下线、新旧密码、长度/一致性/重名校验、留空密码、日志记录
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.4.0
2026-10-19 00:21:47 +08:00
subtrack 8cfc9f5891 feat(v1.3): 管理员(打手)颜色,客户归属打手,日历/待办按颜色标识
1) 管理员颜色
- users 表新增 color;设置 → 管理员 每行一个颜色选择器,保存即生效
- 新建管理员自动从 8 色调色板分配,尽量不重复;支持 #RRGGBB 校验,非法值拒绝

2) 客户归属打手
- customers 表新增 assignee_id(可空,指向 users)
- 新建/编辑客户时可选择「归属打手」,客户详情页也能直接改派
- 语义:一个客户一个打手,该客户之后的所有单子都归这个打手
- 停用的账户不出现在打手候选里;未指派显示灰色「未指派」

3) 日历
- 每个日期格左侧新增 5px 竖条,按当天涉及的打手颜色等分显示(几种颜色几段)
- 竖条按“当天全部任务”去重计算,不受格内色签 3 条上限影响
- 未指派的任务用中性灰段落

4) 待办与大屏
- 「当日待完成」每条任务左侧 3px 打手色条 + 圆点色标 + 打手名字,未指派灰字
- 大屏:今日待打列表、热力格竖条、悬停浮层(新增 who / who_color 字段)统一使用同一套颜色
- 加急样式改为红底,避免与打手色左边框冲突

数据库兼容性:
- 只做加法:users.color、customers.assignee_id 两列;老库打开时自动 ALTER,可重复迁移
- 原有表结构、字段、页面接口不变;未指派的存量数据照常工作

测试:
- 新增单测:颜色自动分配/规范化/非法值拒绝、客户指派与取消、订阅联表带出打手、停用账户不进候选
- 旧库迁移测试扩展:老 users / customers 表自动补列并可写
- 功能验证 16 项(颜色保存、指派、详情展示、日历多色竖条分段、大屏列表与浮层接口)全部通过
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.3.0
2026-10-19 00:16:34 +08:00
subtrack 85f98dff5c feat(v1.2): 亮暗主题、按尾号快速完成、加急置顶、按套餐分日历、大屏升级
1) 全站亮色 / 暗色
- 管理端与大屏都提供切换按钮(顶栏 ☾/☀、大屏右上角),偏好写 cookie,服务端首屏直出,不闪
- 未选择时跟随系统(prefers-color-scheme);两套配色都改成 CSS 变量,色签、热力、表格、抽屉全部适配
- 大屏默认仍是深色记分板,浅色为纸感背景

2) 工作台 → 当日待完成:按手机尾号快速完成
- 输入尾号回车:唯一匹配直接完成并提示
- 同尾号多客户 / 一单多服务:跳到选择面板,默认全选,可多选后一次完成
- 新增 POST /checkins/quick 与 POST /checkins/batch;抽屉标题改为「未完成数 / 总单数」

3) 加急
- 每条任务右侧可勾选/取消加急;加急任务红 tag + 置顶(抽屉与大屏「今日待打」)
- 日历格子出现红点,单据色签加红边;大屏热力格也带红点
- 新增表 task_flags(sub_id, service_date, urgent),按 订阅+日期 存,避免和打卡状态耦合

4) 套餐「在日历中独立显示」
- 套餐表单新增勾选项,列表页显示「独立日历」标记
- 勾选后工作台与大屏顶部出现套餐标签页(全部 / 每日任务 / …),可只看某类委托的排单、热量与待办
- 新增列 packages.calendar_separate;老库打开时自动 ALTER,无需手工迁移

5) 大屏
- 热力默认改为「每日待完成单量」,右上角可切回「到期热力」
- 鼠标悬停任意日期格,浮出当日待办明细(客户、套餐、剩余、加急),数据来自 GET /api/screen/day
- 底部面板改为「今日到期」(左)+「今日待打」(右,加急置顶),最近追加改为最底部细条

数据库兼容性:
- 只做加法:新增 task_flags 表与 packages.calendar_separate 列,原有表结构、字段、接口与已有数据均不变
- 迁移可重复执行(CREATE TABLE IF NOT EXISTS + PRAGMA 检查后 ALTER)

测试:
- 新增单元测试:套餐独立日历标记、加急增删查、旧库自动加列与重复迁移
- 新增功能冒烟 33 项(主题 cookie、套餐标签、快速完成单/多匹配、加急排序、大屏接口)全部通过
- 原有 119 项验收回归全绿(3 条断言按新 UI 更新:抽屉标题、热力默认口径、今日待打数取值方式)
- go vet / go test ./... 通过;版本号 1.2.0
2026-10-18 22:07:25 +08:00
subtrack 099385e3d9 test: 配置用例改用临时目录绝对路径,兼容 Linux runner
build / build-and-test (push) Successful in 3m33s
2026-10-18 19:49:49 +08:00
subtrack 082c1f5778 fix(ci): 补齐 go.sum 中 Linux 专属依赖,修复 go vet missing go.sum entry 2026-10-18 19:49:22 +08:00
subtrack e0741832d3 fix(test): 测试脚本改为位置无关,去除本地绝对路径 2026-10-18 19:46:50 +08:00
53 changed files with 6406 additions and 1243 deletions
+2
View File
@@ -29,6 +29,8 @@ testrun2/
freshtest/
cfgtest/
cfgtest-run/
config-run/
portability-run/
portable-run/
# 截图辅助
+74 -4
View File
@@ -1,7 +1,7 @@
# 代打台 · 代打管理平台
一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡 → 大屏**。
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。界面支持亮色 / 暗色切换,默认跟随系统。
---
@@ -19,7 +19,7 @@
┌───────────────────────────────────────────────┐
│ 代 打 台 · 代打管理平台 │
└───────────────────────────────────────────────┘
版本 1.1.0 配置文件 daili.ini
版本 1.11.5 配置文件 daili.ini
数据库 D:\代打台\daili.db
访问地址:
本机 http://127.0.0.1:17777
@@ -49,6 +49,7 @@
| `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** |
| `open_browser` | `true` | 启动后自动打开浏览器 |
| `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 |
| `portal_public` | `true` | 客户自助查询 `/customer` 免登录;改 `false` 则关闭该页(不影响大屏) |
| `log_enabled` | `true` | 运行日志写文件(控制台始终输出) |
| `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` |
@@ -129,6 +130,59 @@
---
## 4.1 工作台速用
- **按尾号快速完成**:日历右侧「当日待完成」顶部输入手机尾号 → 回车。
- 只匹配到 1 项:直接完成并提示;
- 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。
- **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。
- **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。
- **触摸小键盘**:日历右侧输入框下面有 3 列 4 行数字键盘,底行是 ⌫ / 0 / ✓,点 ✓ 直接提交;按键为自定义样式,不是浏览器原生按钮,触摸目标 52px 高;
- **日历格子**:不再显示排单缩略信息,只保留左侧打手色竖条 + 大字「待完成 / 总数」(如 1/2 = 还剩 1 单,共 2 单);
- **按钮收纳**:任务行只露出「完成」,跳过 / 加急 / 追加收进「⋯」菜单,点空白处自动收起;
- **停用客户**:客户详情页可「停用」(默认从列表、日历、大屏隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复;
- **7 天内到期**:顶栏新增徽标,固定统计未来 7 天内到期的订阅;
- **抽屉时钟**:右侧当日面板顶部有时钟,30px 大写、水平垂直居中,24 小时制精确到秒,下面一行是当天日期;
- **触控 / PC 模式**:尾号输入框右侧有「⌨ / 触控」开关。切到触控时展开 3×4 小键盘、输入框变只读并关闭系统输入法(`inputmode=none`),点键直接写值;切回 PC 收起键盘。选择记在浏览器里。
- **格子计数**:工作台和大屏的日历格都显示「已完成 / 总数」,例如 2/5 = 5 单里做完 2 单。
- **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余、打手,以及撤销/加急/追加;未完成的行不受影响;
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
- 「当日待完成」每条前面有打手色条 + 名字,未指派的显示灰色「未指派」;
- 大屏的今日待打、悬停浮层、热力格竖条同样带颜色。
- **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;没选过时由页面在首屏按系统偏好自动决定。整套界面只认 `<html data-bs-theme>` 一个开关,不会出现「半亮半暗」。
- **升级不缓存旧界面**: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 套餐标签与批量开通
- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。
- **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数/次数后一次开通;重复的会自动跳过并提示。
- **客户月历**:客户详情页新增「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。
- **按次到期**:按次订阅的「到期日」仍是选填;填了到期后状态显示「已到期」、不再进日历待排。
---
## 5. 数据、备份与导出
| 文件 | 说明 |
@@ -141,6 +195,7 @@
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
- **重置**:关闭程序 → 删除 `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` 列,老版本数据库打开时会**自动加列**,不用手工迁移;
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
@@ -153,7 +208,12 @@
- 地址:`http://<本机IP>:17777/screen`
- 默认 `screen_public = true`:**免登录**,电视/副屏直接打开即可(仅建议局域网使用);
- 改成 `screen_public = false` 后大屏回到登录保护;
- 深色「记分板」风格:客户数 / 活跃订阅 / 今日到期 / 7 天内到期、到期热力月历、即将到期 TOP10、今日待打、最近追加;
- 「记分板」风格,亮 / 暗主题同样可切(右上角 ☾ / ☀):
- 顶部 KPI:客户 / 活跃订阅 / 今日到期 / 7 天内到期;
- 中部:**热力月历 + 即将到期 TOP10**。热力默认统计「每日待完成单量」,点右上角按钮可切回「到期热力」;鼠标悬停任意格子会浮出当日待办明细(含加急标记);
- 底部:**今日到期**(左)+ **今日待打**(右,加急置顶带红 tag);
- 最下方细条:最近追加流水;
- 套餐勾选「在日历中独立显示」后,大屏顶部会出现套餐标签,可切换只看某类委托;
- 默认 30 秒自动刷新(设置页可改),按 `F` 全屏。
---
@@ -172,6 +232,10 @@
>
> **为什么内嵌时区库**:Windows 没有 `/usr/share/zoneinfo`,为了 `timezone = Asia/Shanghai` 在任何机器上都可用,引入了 `time/tzdata`(约 0.5 MB)。
> 改了依赖之后,请在 Windows 上额外跑一次 `GOOS=linux go build ./...` 再提交 `go.sum`:
> 有些依赖(例如 `modernc.org/libc` 的 Linux 专用文件)只在 Linux 构建路径下被编译,
> 只在本机 Windows 构建会漏掉它们的 `go.sum` 条目,导致 CI 报 `missing go.sum entry`。
测试:
```powershell
@@ -243,12 +307,18 @@ daili.ini 首次运行自动生成
单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。
**能放到公网吗?**
不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public` 改成 `false`。
不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏和客户自助页默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public`、`portal_public` 改成 `false`。
---
## 9. 安全说明
- **初始管理员**(第一个创建的账号,`id=1`)在 `设置 → 管理员` 每行可以点「编辑」,修改其他管理员的用户名、昵称,或重置密码;
- 重置密码会立刻清除对方的登录会话,他需要用新密码重新登录;昵称/用户名的修改不影响会话;
- 自己那一行不显示编辑(改自己的密码用「修改我的密码」),避免误操作把自己锁在门外;
- 其余管理员权限相同,但只能改自己的密码。
- 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆;
- 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询;
- 服务端模板全部走 `html/template` 自动转义;
+2
View File
@@ -1,5 +1,7 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+12
View File
@@ -34,6 +34,7 @@ type Config struct {
Timezone string
OpenBrowser bool
ScreenPublic bool
PortalPublic bool
LogEnabled bool
LogDir string
@@ -57,6 +58,7 @@ func Defaults() *Config {
Timezone: "",
OpenBrowser: true,
ScreenPublic: true,
PortalPublic: true,
LogEnabled: true,
LogDir: "logs",
}
@@ -203,6 +205,12 @@ func (c *Config) set(key, val string) error {
return fmt.Errorf("screen_public %v", err)
}
c.ScreenPublic = b
case "portal_public":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("portal_public %v", err)
}
c.PortalPublic = b
case "log_enabled":
b, err := parseBool(val)
if err != nil {
@@ -307,6 +315,9 @@ open_browser = %t
# 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录)
screen_public = %t
# 客户自助查询 /customer 是否免登录(默认 true;不需要对外时可改成 false)
portal_public = %t
# ---------- 日志 ----------
# 运行日志同时输出到控制台与 log_dir 下按天切分的文件
log_enabled = %t
@@ -322,6 +333,7 @@ log_dir = %s
c.Timezone,
c.OpenBrowser,
c.ScreenPublic,
c.PortalPublic,
c.LogEnabled,
c.LogDir,
)
+3 -2
View File
@@ -64,6 +64,7 @@ func TestLoadCreatesDefaultFile(t *testing.T) {
func TestLoadParsesValues(t *testing.T) {
dir := t.TempDir()
absLogDir := t.TempDir() // 各平台都是绝对路径,避免把 Windows 盘符写进用例
body := "\ufeff# 注释\n" +
"listen_ip = 127.0.0.1\r\n" +
"\r\n" +
@@ -75,7 +76,7 @@ func TestLoadParsesValues(t *testing.T) {
"open_browser = 0\r\n" +
"screen_public = false\r\n" +
"log_enabled = on\r\n" +
"log_dir = D:\\logs\r\n"
"log_dir = " + absLogDir + "\r\n"
path := writeFile(t, dir, FileName, body)
cfg, err := Load(path, dir)
@@ -97,7 +98,7 @@ func TestLoadParsesValues(t *testing.T) {
if cfg.Timezone != "Asia/Shanghai" || cfg.OpenBrowser || cfg.ScreenPublic || !cfg.LogEnabled {
t.Fatalf("其余配置不对: %+v", cfg)
}
if cfg.LogDir != "D:\\logs" {
if cfg.LogDir != absLogDir {
t.Fatalf("绝对路径被改写: %s", cfg.LogDir)
}
if cfg.Addr() != "127.0.0.1:19000" {
+23
View File
@@ -74,6 +74,29 @@ const (
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 (
StateActive = "active" // 进行中
+77 -3
View File
@@ -44,7 +44,35 @@ func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int
}
delta := 0
if s.Unit == domain.UnitCount {
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 {
@@ -103,8 +131,29 @@ func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
if err != nil {
return err
}
delta := 0
if s.Unit == domain.UnitCount && status == "done" {
newUsed := s.UsedCount - 1
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
}
@@ -120,7 +169,6 @@ func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
// CheckinsBetween 取区间内打卡,索引为 [subID][date]。
func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
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
@@ -128,6 +176,32 @@ func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, er
if err != nil {
return nil, err
}
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) {
out := map[int64]map[string]Checkin{}
defer rows.Close()
for rows.Next() {
var c Checkin
+124 -11
View File
@@ -23,6 +23,14 @@ type Customer struct {
CreatedAt string
UpdatedAt string
// 归属打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
// Archived 停用(软删除):默认隐藏,订阅保留但不上日历
Archived bool
// 列表聚合
SubCount int
Summary string
@@ -41,28 +49,31 @@ func (c *Customer) TagList() []string {
return out
}
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at`
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)`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer
var archived int
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,
&c.AssigneeID, &c.AssigneeName, &c.AssigneeColor, &archived)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
c.Archived = archived == 1
return &c, nil
}
// CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,assignee_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, now, now)
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), now, now)
if err != nil {
if isUnique(err) {
return 0, ErrDuplicate
@@ -74,10 +85,10 @@ func (db *DB) CreateCustomer(c *Customer) (int64, error) {
// UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error {
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,assignee_id=?,updated_at=?
WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nowStamp(), c.ID)
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), nowStamp(), c.ID)
if err != nil && isUnique(err) {
return ErrDuplicate
}
@@ -90,12 +101,19 @@ func (db *DB) Customer(id int64) (*Customer, error) {
}
// ListCustomers 分页查询客户,并聚合订阅摘要。
func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int) ([]Customer, int, error) {
func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int, includeArchived bool) ([]Customer, int, error) {
where := "1=1"
args := []any{}
if !includeArchived {
where = "IFNULL(archived,0)=0"
}
if q = strings.TrimSpace(q); q != "" {
like := "%" + q + "%"
where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)"
if includeArchived {
where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)"
} else {
where = "IFNULL(archived,0)=0 AND (tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)"
}
args = append(args, like, like, like, like, like)
}
var total int
@@ -154,7 +172,25 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
continue
}
var text string
if s.Unit == domain.UnitDay {
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 {
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
@@ -196,6 +232,83 @@ func (db *DB) FindCustomerByTailName(tail, name string) (*Customer, error) {
// CustomerCount 客户总数。
func (db *DB) CustomerCount() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n)
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE IFNULL(archived,0)=0`).Scan(&n)
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 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。
func (db *DB) CustomersByTail(tail string) ([]Customer, error) {
tail = strings.TrimSpace(tail)
if tail == "" {
return nil, nil
}
out, err := db.CustomersByTailExact(tail)
if err != nil {
return nil, err
}
if len(out) > 0 {
return out, nil
}
return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail LIKE ? ORDER BY id LIMIT 20`, "%"+tail+"%")
}
func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Customer{}
for rows.Next() {
c, err := scanCustomer(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
// SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历与大屏。
func (db *DB) SetCustomerArchived(id int64, archived bool) error {
v := 0
if archived {
v = 1
}
res, err := db.sql.Exec(`UPDATE customers SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteCustomer 彻底删除客户;订阅、打卡、暂停、追加记录随外键级联删除。
func (db *DB) DeleteCustomer(id int64) error {
res, err := db.sql.Exec(`DELETE FROM customers WHERE id=?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CountArchivedCustomers 统计已停用客户数。
func (db *DB) CountArchivedCustomers() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE IFNULL(archived,0)=1`).Scan(&n)
return n, err
}
+121 -1
View File
@@ -116,6 +116,101 @@ func (db *DB) Migrate() error {
return fmt.Errorf("迁移失败: %w\nSQL: %s", err, stmt)
}
}
// 增量列:老库升级时补上(SQLite 没有 ADD COLUMN IF NOT EXISTS)
if err := db.ensureColumn("packages", "calendar_separate",
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// v1.3:管理员颜色 + 客户归属打手
if err := db.ensureColumn("users", "color",
"ALTER TABLE users ADD COLUMN color 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
}
if err := db.ensureColumn("customers", "archived",
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
if err := db.ensureColumn("packages", "tag",
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
// 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",
"ALTER TABLE packages ADD COLUMN default_validity_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
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",
"ALTER TABLE packages ADD COLUMN season_end TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("subscriptions", "season",
"ALTER TABLE subscriptions ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
return 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.ensureColumn("packages", "abyss",
"ALTER TABLE packages ADD COLUMN abyss INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("packages", "cycle_days",
"ALTER TABLE packages ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
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",
"ALTER TABLE subscriptions ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
return nil
}
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
func (db *DB) ensureColumn(table, column, ddl string) error {
rows, err := db.sql.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return fmt.Errorf("读取 %s 表结构失败: %w", table, err)
}
found := false
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if strings.EqualFold(name, column) {
found = true
}
}
rows.Close()
if found {
return nil
}
if _, err := db.sql.Exec(ddl); err != nil {
return fmt.Errorf("迁移失败(%s.%s): %w", table, column, err)
}
return nil
}
@@ -126,6 +221,7 @@ var schemaStatements = []string{
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_login_at TEXT
)`,
@@ -142,10 +238,18 @@ var schemaStatements = []string{
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#2F5D50',
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
)`,
@@ -159,6 +263,8 @@ var schemaStatements = []string{
tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
assignee_id INTEGER REFERENCES users(id),
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tail, name)
@@ -173,6 +279,10 @@ var schemaStatements = []string{
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 REFERENCES users(id),
created_at TEXT NOT NULL,
@@ -224,6 +334,16 @@ var schemaStatements = []string{
ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS task_flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
service_date TEXT NOT NULL,
urgent INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (sub_id, service_date)
)`,
`CREATE INDEX IF NOT EXISTS idx_task_flags_date ON task_flags(service_date)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
+124 -17
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"errors"
"strconv"
"strings"
"daili/internal/domain"
@@ -20,26 +21,69 @@ type Package struct {
Note string
Archived bool
SortOrder int
CreatedAt string
UpdatedAt string
SubCount int
ActiveCount int
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
CalendarSeparate bool
// Tag 套餐标签:相同标签的套餐在日历里合并显示
Tag string
// Season 通行证模式:开始~结束之间任意一天可整段完成
Season bool
// Abyss 深渊模式:CycleDays 天一个周期,共 DefaultAmount 个周期
Abyss bool
CycleDays int
// DefaultValidityDays 按次套餐默认有效期(天),0 不限
DefaultValidityDays int
// 通行证的固定区间(套餐级)
SeasonStart string
SeasonEnd string
CreatedAt string
UpdatedAt string
SubCount int
ActiveCount int
}
// UnitLabel 返回类型中文名。
func (p *Package) UnitLabel() string {
if p.Abyss {
return "深渊"
}
if p.Season {
return "通行证"
}
if p.Unit == domain.UnitCount {
return "按次"
}
return "按日"
}
// AmountText 返回「默认量」列的中文描述。
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) + " 天"
}
// CycleLabel 返回深渊模式的人话描述。
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) {
var p Package
var price sql.NullInt64
var archived int
var archived, separate, season, validity, abyss, cycleDays int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd,
&abyss, &cycleDays, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -47,6 +91,11 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
return nil, err
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
p.Season = season == 1
p.Abyss = abyss == 1
p.CycleDays = cycleDays
p.DefaultValidityDays = validity
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
@@ -56,7 +105,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
// ListPackages 列出套餐(含订阅计数)。
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.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.season,p.default_validity_days,p.season_start,p.season_end,p.abyss,p.cycle_days,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 AND s.status<>'closed')
FROM packages p`
@@ -73,12 +122,18 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
for rows.Next() {
var p Package
var price sql.NullInt64
var archived int
var archived, separate, season, validity, abyss, cycleDays int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd,
&abyss, &cycleDays, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
p.Season = season == 1
p.Abyss = abyss == 1
p.CycleDays = cycleDays
p.DefaultValidityDays = validity
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
@@ -90,7 +145,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
// Package 取单个套餐。
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,created_at,updated_at
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
FROM packages WHERE id=?`, id))
}
@@ -102,15 +157,15 @@ func (db *DB) CreatePackage(p *Package) (int64, error) {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
p.Color = "#3B6EA5"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, now, now)
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)
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)
if err != nil {
return 0, err
}
@@ -124,17 +179,25 @@ func (db *DB) UpdatePackage(p *Package) error {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
p.Color = "#3B6EA5"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, nowStamp(), p.ID)
_, 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=?
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)
return err
}
// b2i 把 bool 转成 SQLite 的 0/1。
func b2i(v bool) int {
if v {
return 1
}
return 0
}
// SetPackageArchived 归档/取消归档。
func (db *DB) SetPackageArchived(id int64, archived bool) error {
v := 0
@@ -144,3 +207,47 @@ func (db *DB) SetPackageArchived(id int64, archived bool) error {
_, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
return err
}
// SubscribedCustomerIDs 返回已订阅某套餐的客户 ID 集合(含已关闭,用于批量订阅时置灰)。
func (db *DB) SubscribedCustomerIDs(packageID int64) (map[int64]bool, error) {
rows, err := db.sql.Query(`SELECT customer_id FROM subscriptions WHERE package_id=?`, packageID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]bool{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = true
}
return out, rows.Err()
}
// PackageTags 返回所有非空标签(去重,按字典序)。
func (db *DB) PackageTags() ([]string, error) {
rows, err := db.sql.Query(`SELECT DISTINCT tag FROM packages WHERE IFNULL(tag,'')<>'' ORDER BY tag`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// SeasonExpired 判断包赛季套餐在 today 是否已结束。
func (p Package) SeasonExpired(today string) bool {
if !p.Season || p.SeasonEnd == "" {
return false
}
return today > p.SeasonEnd
}
+21 -12
View File
@@ -22,13 +22,18 @@ type Stats struct {
}
// Badges 顶栏常驻徽标。
func (db *DB) Badges(today time.Time) (dueToday, due3 int, err error) {
func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) {
t := domain.Date(today)
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`, t).Scan(&dueToday); err != nil {
const notArchived = ` AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`+notArchived, t).Scan(&dueToday); err != nil {
return
}
err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
t, domain.Date(domain.AddDays(today, 2))).Scan(&due3)
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`+notArchived,
t, domain.Date(domain.AddDays(today, 2))).Scan(&due3); err != nil {
return
}
err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`+notArchived,
t, domain.Date(domain.AddDays(today, 6))).Scan(&due7)
return
}
@@ -42,14 +47,17 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
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'`).Scan(&st.PausedSubs); err != nil {
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=?`,
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 ?`,
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
}
@@ -60,8 +68,9 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
// 本月到期热力
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
WHERE status<>'closed' AND end_date BETWEEN ? AND ? GROUP BY end_date`,
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
@@ -80,12 +89,12 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
// TOP:按日按剩余天数、按次按剩余次数,各自排序后交替取前 10
t := domain.Date(today)
daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='day' AND s.end_date>=?
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 s.unit='count' AND s.used_count < s.total_count
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 {
@@ -109,6 +118,6 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
// RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。
func (db *DB) RecentDue(from string, days int) ([]Subscription, error) {
to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1))
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date BETWEEN ? AND ?
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date BETWEEN ? AND ?
ORDER BY s.end_date, s.id`, from, to)
}
+498 -3
View File
@@ -2,6 +2,7 @@ package store
import (
"database/sql"
"errors"
"os"
"path/filepath"
"strconv"
@@ -342,7 +343,7 @@ func TestSettingsAndBadges(t *testing.T) {
_, _ = db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -30)), Days: 31,
})
due, due3, err := db.Badges(today)
due, due3, _, err := db.Badges(today)
if err != nil {
t.Fatal(err)
}
@@ -356,14 +357,14 @@ func TestSearchByTail(t *testing.T) {
mustCustomer(t, db, "8899", "阿飞")
mustCustomer(t, db, "8890", "阿飞二号")
today := domain.Today()
list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3)
list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3, false)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(list) != 1 || list[0].Name != "阿飞" {
t.Fatalf("搜索命中 %d 条", total)
}
list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3)
list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3, false)
if total != 2 {
t.Fatalf("前缀搜索应命中 2 条,实际 %d", total)
}
@@ -647,3 +648,497 @@ func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
t.Fatalf("旧库未切到单文件模式: %s", mode)
}
}
func TestPackageCalendarSeparate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "每日任务", domain.UnitDay, 30)
if pkg.CalendarSeparate {
t.Fatal("新建套餐默认不应勾选独立日历")
}
pkg.CalendarSeparate = true
if err := db.UpdatePackage(pkg); err != nil {
t.Fatal(err)
}
got, err := db.Package(pkg.ID)
if err != nil || !got.CalendarSeparate {
t.Fatalf("独立日历标记未保存: %+v err=%v", got, err)
}
list, err := db.ListPackages(true)
if err != nil || len(list) != 1 || !list[0].CalendarSeparate {
t.Fatalf("列表未带独立日历标记: %+v err=%v", list, err)
}
got.CalendarSeparate = false
if err := db.UpdatePackage(got); err != nil {
t.Fatal(err)
}
if again, _ := db.Package(pkg.ID); again.CalendarSeparate {
t.Fatal("取消勾选失败")
}
}
func TestTaskUrgentFlags(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "4321", "老王")
sub1, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
pkg2 := mustPackage(t, db, "按次", domain.UnitCount, 10)
sub2, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()),
TotalCount: 10, EndDate: domain.Date(domain.AddDays(domain.Today(), 30)),
})
if err != nil {
t.Fatal(err)
}
d1 := domain.Date(domain.Today())
d2 := domain.Date(domain.AddDays(domain.Today(), 1))
if err := db.SetUrgent(sub1.ID, d1, true); err != nil {
t.Fatal(err)
}
if err := db.SetUrgent(sub2.ID, d2, true); err != nil {
t.Fatal(err)
}
flags, err := db.UrgentFlags(d1, d2)
if err != nil {
t.Fatal(err)
}
if !flags[sub1.ID][d1] || !flags[sub2.ID][d2] {
t.Fatalf("加急标记缺失: %+v", flags)
}
if ok, _ := db.IsUrgent(sub1.ID, d1); !ok {
t.Fatal("IsUrgent 应为 true")
}
if n, _ := db.CountUrgent(d1, d2); n != 2 {
t.Fatalf("加急计数=%d 期望 2", n)
}
// 重复设置不报错(UPSERT)
if err := db.SetUrgent(sub1.ID, d1, true); err != nil {
t.Fatalf("重复标记加急失败: %v", err)
}
// 取消
if err := db.SetUrgent(sub1.ID, d1, false); err != nil {
t.Fatal(err)
}
if ok, _ := db.IsUrgent(sub1.ID, d1); ok {
t.Fatal("取消加急后仍为 true")
}
if n, _ := db.CountUrgent(d1, d2); n != 1 {
t.Fatalf("取消后计数=%d 期望 1", n)
}
// 非法日期
if err := db.SetUrgent(sub1.ID, "2026-13-40", true); err == nil {
t.Fatal("非法日期应报错")
}
// 不存在的订阅
if err := db.SetUrgent(99999, d1, true); err == nil {
t.Fatal("不存在的订阅应报错")
}
}
// TestMigrateOldDatabaseAddsColumn 模拟 v1.1 的旧库:packages 表没有 calendar_separate。
func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "old.db")
dsn := strings.ReplaceAll(path, "\\", "/")
old, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
unit TEXT NOT NULL,
default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#2F5D50',
note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`INSERT INTO packages(name,unit,default_amount,color,note,archived,sort_order,created_at,updated_at)
VALUES('老套餐','day',30,'#2F5D50','',0,1,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
last_login_at TEXT
)`,
`INSERT INTO users(username,display_name,password_hash,is_active,created_at)
VALUES('admin','老板','x',1,'2026-01-01 00:00:00')`,
`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 '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tail, name)
)`,
`INSERT INTO customers(tail,name,created_at,updated_at)
VALUES('1001','老客户','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
}
for _, s := range stmts {
if _, err := old.Exec(s); err != nil {
t.Fatal(err)
}
}
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)
}
pkg, err := db.Package(1)
if err != nil {
t.Fatalf("读取老套餐失败: %v", err)
}
if pkg.Name != "老套餐" || pkg.CalendarSeparate {
t.Fatalf("老数据/默认值不对: %+v", pkg)
}
// 新列可写
pkg.CalendarSeparate = true
if err := db.UpdatePackage(pkg); err != nil {
t.Fatal(err)
}
if got, _ := db.Package(1); !got.CalendarSeparate {
t.Fatal("迁移后新列不可写")
}
// 老库的 users / customers 也补上了新列
var userColor string
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)
}
if c.AssigneeID != 0 {
t.Fatalf("老客户不应有打手: %+v", c)
}
if err := db.SetCustomerAssignee(1, 1); err != nil {
t.Fatalf("老库 customers.assignee_id 不可写: %v", err)
}
if c2, _ := db.Customer(1); c2.AssigneeID != 1 {
t.Fatalf("指派未生效: %+v", c2)
}
// 新表 task_flags 已建立
var tbl string
if err := db.sql.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='task_flags'`).Scan(&tbl); err != nil {
t.Fatalf("task_flags 表未建立: %v", err)
}
// 迁移可重复执行
if err := db.Migrate(); err != nil {
t.Fatalf("重复迁移失败: %v", err)
}
}
func TestUserColorAndAssignee(t *testing.T) {
db := newTestDB(t)
// 新建管理员自动分配调色板颜色
u1, err := db.CreateUser("dashou1", "阿强", "daili123")
if err != nil {
t.Fatal(err)
}
u2, err := db.CreateUser("dashou2", "小美", "daili123")
if err != nil {
t.Fatal(err)
}
if u1.Color == "" || u2.Color == "" || u1.Color == u2.Color {
t.Fatalf("自动颜色不合理: %q %q", u1.Color, u2.Color)
}
// 改色(小写转大写)
if err := db.SetUserColor(u2.ID, "#c0392b"); err != nil {
t.Fatal(err)
}
got, err := db.UserByID(u2.ID)
if err != nil || got.Color != "#C0392B" {
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.SetCustomerAssignee(cid, u1.ID); err != nil {
t.Fatal(err)
}
c, err := db.Customer(cid)
if err != nil {
t.Fatal(err)
}
if c.AssigneeID != u1.ID || c.AssigneeName != "阿强" || c.AssigneeColor != "#2F5D50" {
t.Fatalf("客户指派信息不对: %+v", c)
}
// 取消指派
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 != nil {
t.Fatal(err)
}
c3, _ := db.Customer(cid2)
if c3.AssigneeName != "小美" || c3.AssigneeColor != "#C0392B" {
t.Fatalf("新建客户的指派未生效: %+v", c3)
}
// 订阅联表能带出打手
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) {
db := newTestDB(t)
admin, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
u, err := db.CreateUser("dashou", "阿强", "dashou123")
if err != nil {
t.Fatal(err)
}
// 建一个会话,稍后用密码重置把它踢掉
token := "tok-" + domain.Date(domain.Today())
if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser(token); err != nil {
t.Fatalf("会话应有效: %v", err)
}
// 只改昵称:会话保留
if err := db.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil {
t.Fatal(err)
}
got, _ := db.UserByID(u.ID)
if got.DisplayName != "强哥" || got.Username != "dashou" {
t.Fatalf("昵称未更新: %+v", got)
}
if _, err := db.SessionUser(token); err != nil {
t.Fatalf("未改密码不应踢下线: %v", err)
}
// 改用户名 + 重置密码:会话失效,新密码可登录
if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser(token); err == nil {
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)
}
}
func TestCustomerArchiveFilters(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
// 一个正常客户 + 一个按次客户
cid := mustCustomer(t, db, "7001", "小张")
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 10,
})
if err != nil {
t.Fatal(err)
}
// 打卡 + 加急,稍后验证级联删除
if err := db.SaveCheckin(sub.ID, domain.Date(domain.Today()), "done", "", 0); err != nil {
t.Fatal(err)
}
if err := db.SetUrgent(sub.ID, domain.Date(domain.Today()), true); err != nil {
t.Fatal(err)
}
today := domain.Date(domain.Today())
subs, err := db.CalendarSubs(today, today)
if err != nil || len(subs) != 1 {
t.Fatalf("停用前日历应有 1 条: %v err=%v", len(subs), err)
}
if n, _ := db.CustomerCount(); n != 1 {
t.Fatalf("停用前客户数=1, got %d", n)
}
// 停用
if err := db.SetCustomerArchived(cid, true); err != nil {
t.Fatal(err)
}
if subs, _ := db.CalendarSubs(today, today); len(subs) != 0 {
t.Fatalf("停用后不应上日历: %d", len(subs))
}
if due, _ := db.DueOn(domain.Date(domain.AddDays(domain.Today(), 9))); len(due) != 0 {
t.Fatalf("停用后不应提醒到期: %d", len(due))
}
if n, _ := db.CustomerCount(); n != 0 {
t.Fatalf("停用后客户数应为 0, got %d", n)
}
if list, total, _ := db.ListCustomers("", 50, 0, today, 3, false); total != 0 || len(list) != 0 {
t.Fatalf("默认列表不应包含停用客户: total=%d", total)
}
if list, total, _ := db.ListCustomers("", 50, 0, today, 3, true); total != 1 || len(list) != 1 || !list[0].Archived {
t.Fatalf("已停用视图应包含: total=%d list=%+v", total, list)
}
if c, _ := db.Customer(cid); !c.Archived {
t.Fatal("Customer() 应返回 Archived=true")
}
if due, _, _, _ := db.Badges(domain.Today()); due != 0 {
t.Fatalf("停用后徽标应为 0, got %d", due)
}
if all, _ := db.CustomersByTail("7001"); len(all) != 0 {
t.Fatalf("停用客户不应被尾号搜到: %d", len(all))
}
// 恢复
if err := db.SetCustomerArchived(cid, false); err != nil {
t.Fatal(err)
}
if subs, _ := db.CalendarSubs(today, today); len(subs) != 1 {
t.Fatalf("恢复后应回到日历: %d", len(subs))
}
// 彻底删除:订阅/打卡/加急一起消失
if err := db.DeleteCustomer(cid); err != nil {
t.Fatal(err)
}
if _, err := db.Customer(cid); err == nil {
t.Fatal("删除后不应还能查到客户")
}
if subs, _ := db.CalendarSubs(today, today); len(subs) != 0 {
t.Fatalf("删除后订阅应级联消失: %d", len(subs))
}
var flags, checkins int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags`).Scan(&flags)
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM checkins`).Scan(&checkins)
if flags != 0 || checkins != 0 {
t.Fatalf("删除后打卡/加急应级联删除: flags=%d checkins=%d", flags, checkins)
}
if err := db.DeleteCustomer(9999); err == nil {
t.Fatal("删除不存在的客户应报错")
}
}
func TestPackageTag(t *testing.T) {
db := newTestDB(t)
p1 := mustPackage(t, db, "包月活动托", domain.UnitDay, 30)
if p1.Tag != "" {
t.Fatalf("默认标签应为空: %q", p1.Tag)
}
p1.Tag = "包月"
if err := db.UpdatePackage(p1); err != nil {
t.Fatal(err)
}
got, err := db.Package(p1.ID)
if err != nil || got.Tag != "包月" {
t.Fatalf("标签未保存: %+v err=%v", got, err)
}
p2 := &Package{Name: "深渊委托", Unit: domain.UnitCount, DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "}
if _, err := db.CreatePackage(p2); err != nil {
t.Fatal(err)
}
tags, err := db.PackageTags()
if err != nil || len(tags) != 1 || tags[0] != "包月" {
t.Fatalf("标签去重/去空格失败: %+v err=%v", tags, err)
}
list, err := db.ListPackages(true)
if err != nil || len(list) != 2 {
t.Fatalf("列表应有 2 个套餐: %d err=%v", len(list), err)
}
for _, p := range list {
if p.Tag != "包月" {
t.Fatalf("列表标签不对: %+v", p)
}
}
}
+121 -8
View File
@@ -26,6 +26,12 @@ type Subscription struct {
CreatedAt string
UpdatedAt string
ClosedAt string
// Season 通行证;SeasonDone 标记整段完成的日期(空=未完成)
Season bool
SeasonDone string
// Abyss 深渊模式:CycleDays 天一个周期,TotalCount 是周期总数
Abyss bool
CycleDays int
// 联表
PackageName string
@@ -33,6 +39,11 @@ type Subscription struct {
CustomerTail string
CustomerName string
CustomerContact string
// 客户归属的打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
}
// Eval 计算展示状态。
@@ -43,6 +54,34 @@ func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
// 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 暂停分段。
type Pause struct {
ID int64
@@ -93,22 +132,32 @@ func (a *Adjustment) KindLabel() string {
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,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,''),
p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
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,''),
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
JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_id`
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
var s Subscription
var seasonInt, abyssInt, cycleDays int
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
&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 errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
s.Season = seasonInt == 1
s.Abyss = abyssInt == 1
s.CycleDays = cycleDays
return &s, nil
}
@@ -223,14 +272,14 @@ func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
// CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。
func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed'
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0
AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ?
ORDER BY s.id`, to, from)
}
// DueOn 某天到期的订阅。
func (db *DB) DueOn(date string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date=? ORDER BY s.id`, date)
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date=? ORDER BY s.id`, date)
}
// CreateSubInput 新建订阅入参。
@@ -254,6 +303,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅")
}
if pkg.Season && strings.TrimSpace(in.StartDate) == "" {
in.StartDate = pkg.SeasonStart
}
start, err := domain.ParseDate(in.StartDate)
if err != nil {
return nil, errors.New("开始日期格式不正确")
@@ -300,8 +352,42 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
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()
var id int64
@@ -315,9 +401,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
if !errors.Is(err, sql.ErrNoRows) {
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)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?)`,
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now)
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)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?,?,?)`,
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)
if err != nil {
if isUnique(err) {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
@@ -584,7 +670,8 @@ func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
// CountActiveSubs 活跃订阅数。
func (db *DB) CountActiveSubs(today string) (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' AND (
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 (
(s.unit='day' AND s.end_date >= ?) OR
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
)`, today, today).Scan(&n)
@@ -595,3 +682,29 @@ func (db *DB) CountActiveSubs(today string) (int, error) {
func (s *Subscription) SubTitle() string {
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 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。
func (db *DB) DeleteSubscription(subID int64) error {
res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"errors"
"daili/internal/domain"
)
// UrgentFlags 返回 [from,to] 区间内的加急标记,索引为 [subID][date]。
// 表里只存“加急=1”的行,未出现即未加急。
func (db *DB) UrgentFlags(from, to string) (map[int64]map[string]bool, error) {
out := map[int64]map[string]bool{}
rows, err := db.sql.Query(`SELECT sub_id, service_date FROM task_flags
WHERE urgent=1 AND service_date BETWEEN ? AND ?`, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var subID int64
var date string
if err := rows.Scan(&subID, &date); err != nil {
return nil, err
}
if out[subID] == nil {
out[subID] = map[string]bool{}
}
out[subID][date] = true
}
return out, rows.Err()
}
// SetUrgent 设置或取消某天某订阅的加急标记。
func (db *DB) SetUrgent(subID int64, date string, urgent bool) error {
if _, err := domain.ParseDate(date); err != nil {
return errors.New("日期格式不正确")
}
if _, err := db.Sub(subID); err != nil {
return errors.New("订阅不存在")
}
now := nowStamp()
if urgent {
_, err := db.sql.Exec(`INSERT INTO task_flags(sub_id, service_date, urgent, created_at, updated_at)
VALUES(?,?,1,?,?)
ON CONFLICT(sub_id, service_date) DO UPDATE SET urgent=1, updated_at=excluded.updated_at`,
subID, date, now, now)
return err
}
_, err := db.sql.Exec(`DELETE FROM task_flags WHERE sub_id=? AND service_date=?`, subID, date)
return err
}
// IsUrgent 查询单条加急状态。
func (db *DB) IsUrgent(subID int64, date string) (bool, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags WHERE sub_id=? AND service_date=? AND urgent=1`,
subID, date).Scan(&n)
return n > 0, err
}
// CountUrgent 统计区间内加急任务数。
func (db *DB) CountUrgent(from, to string) (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags WHERE urgent=1 AND service_date BETWEEN ? AND ?`,
from, to).Scan(&n)
return n, err
}
+130 -5
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"errors"
"strings"
"time"
"daili/internal/pw"
@@ -14,17 +15,41 @@ type User struct {
Username string
DisplayName string
IsActive bool
Color string
CreatedAt 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 {
Scan(dest ...any) error
}) (*User, error) {
var u User
var active int
var last sql.NullString
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last, &u.Color); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -35,7 +60,7 @@ func scanUser(row interface {
return &u, nil
}
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,''), IFNULL(color,'')`
// CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) {
@@ -51,8 +76,9 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err
}
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
VALUES(?,?,?,1,?)`, username, displayName, hash, now)
color := db.nextAssigneeColor()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, color, created_at)
VALUES(?,?,?,1,?,?)`, username, displayName, hash, color, now)
if err != nil {
if isUnique(err) {
return nil, ErrDuplicate
@@ -60,7 +86,106 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, Color: color, 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 校验用户名口令。
+469 -74
View File
@@ -3,7 +3,9 @@ package web
import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"daili/internal/domain"
@@ -22,9 +24,22 @@ type DayCell struct {
DueCount int
TaskCount int
DoneCount int
UrgentCnt int
Heat int
Tickets []Ticket
More int
// 通行证 / 深渊色块
BlockColor string
BlockDone int
BlockTotal int
BlockText string
BlockTitle string
// BlockPercent 色块底色浓度:通行证固定 18%,深渊按周期依次减淡
BlockPercent int
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
PendingCount int
Tickets []Ticket
More int
// Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰)
Strip []string
}
// Ticket 是日历格里的色签。
@@ -37,6 +52,9 @@ type Ticket struct {
Done bool
Skipped bool
IsDue bool
Urgent bool
// AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底)
AssigneeColor string
}
// TaskRow 是当日待打清单的一行。
@@ -47,40 +65,68 @@ type TaskRow struct {
Skipped bool
Note string
Operator string
Urgent bool
}
// PkgTab 是日历上方的套餐标签。
type PkgTab struct {
ID int64
Tag string
Name string
Color string
Count int
Href string
Active bool
}
type calendarData struct {
Year, Month int
Title string
PrevY, PrevM int
NextY, NextM int
TodayStr string
Weekdays []string
Weeks [][]DayCell
Selected string
SelectedLabel string
SelectedIsToday bool
SelectedIsPast bool
Tasks []TaskRow
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
NoPackages bool
NoCustomers bool
Back string
Year, Month int
Title string
PrevY, PrevM int
NextY, NextM int
TodayStr string
Weekdays []string
Weeks [][]DayCell
Selected string
SelectedLabel string
SelectedIsToday bool
SelectedIsPast bool
Tasks []TaskRow
TaskPending int
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
MonthUrgent int
NoPackages bool
NoCustomers bool
Back string
// 套餐标签(在日历中独立显示的套餐)
Tabs []PkgTab
PkgID int64
Tag string
// FilterQuery 当前筛选条件(pkg=1 或 tag=xxx),拼链接用
FilterQuery string
HasTabs bool
// 快速完成:按尾号筛选出的候选
PickTail string
PickRows []TaskRow
PickMsg string
}
// calSub 是日历计算用的订阅上下文。
type calSub struct {
sub store.Subscription
start time.Time
end time.Time
hasEnd bool
eval domain.Eval
pauses []store.Pause
sub store.Subscription
start time.Time
end time.Time
hasEnd bool
eval domain.Eval
pauses []store.Pause
checkins map[string]store.Checkin
}
func (c calSub) pausedOn(date string) bool {
@@ -95,10 +141,87 @@ func (c calSub) pausedOn(date string) bool {
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 {
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
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 {
return false
}
@@ -108,6 +231,113 @@ func (c calSub) covers(date string) bool {
return !c.pausedOn(date)
}
// buildCalendarTabs 生成日历页签:先按套餐标签合并,再给「独立显示」的套餐各开一个。
func buildCalendarTabs(pkgs []store.Package, subs []store.Subscription, total int) ([]PkgTab, bool) {
tagOrder := []string{}
tagColor := map[string]string{}
tagCount := map[string]int{}
pkgTag := map[int64]string{}
sep := []store.Package{}
for _, p := range pkgs {
if p.Archived {
continue
}
pkgTag[p.ID] = p.Tag
if p.Tag != "" {
if _, ok := tagColor[p.Tag]; !ok {
tagOrder = append(tagOrder, p.Tag)
tagColor[p.Tag] = p.Color
}
}
if p.CalendarSeparate {
sep = append(sep, p)
}
}
for _, sub := range subs {
if t := pkgTag[sub.PackageID]; t != "" {
tagCount[t]++
}
}
if len(tagOrder) == 0 && len(sep) == 0 {
return nil, false
}
tabs := []PkgTab{{ID: 0, Name: "全部", Color: "#8A8F98", Count: total}}
for _, t := range tagOrder {
tabs = append(tabs, PkgTab{Tag: t, Name: t, Color: tagColor[t], Count: tagCount[t]})
}
for _, p := range sep {
cnt := 0
for _, sub := range subs {
if sub.PackageID == p.ID {
cnt++
}
}
tabs = append(tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt})
}
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 非空取该标签下的所有套餐。
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)
if err != nil {
return nil, nil, nil, nil, err
}
if pkgID > 0 || tag != "" {
pkgs, err := s.db.ListPackages(true)
if err != nil {
return nil, nil, nil, nil, err
}
tags := make(map[int64]string, len(pkgs))
for _, p := range pkgs {
tags[p.ID] = p.Tag
}
filtered := make([]store.Subscription, 0, len(subs))
for _, sub := range subs {
if pkgID > 0 && sub.PackageID != pkgID {
continue
}
if pkgID <= 0 && tag != "" && tags[sub.PackageID] != tag {
continue
}
filtered = append(filtered, sub)
}
subs = filtered
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
return nil, nil, nil, nil, err
}
checkins, err := s.db.CheckinsBetween(wideFrom(from), wideTo(to))
if err != nil {
return nil, nil, nil, nil, err
}
urgent, err := s.db.UrgentFlags(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
return subs, pauses, checkins, urgent, nil
}
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
y := s.queryInt(r, "y", today.Year())
@@ -118,6 +348,8 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
monthStart := domain.MonthStart(y, time.Month(m))
monthEnd := domain.MonthEnd(y, time.Month(m))
pkgID := int64(s.queryInt(r, "pkg", 0))
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
sel := r.URL.Query().Get("d")
selT, err := domain.ParseDate(sel)
if sel != "" && err != nil {
@@ -132,39 +364,25 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
selT = domain.ParseDateOr(sel, today)
}
// 网格:以周一为首日,铺满整月所需的周数
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
subs, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd))
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
http.Error(w, "读取暂停记录失败", http.StatusInternalServerError)
return
}
checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd))
if err != nil {
http.Error(w, "读取打卡记录失败", http.StatusInternalServerError)
return
}
warn := s.warnDays()
list := 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],
sub: sub,
start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn),
pauses: pauses[sub.ID],
checkins: checkins[sub.ID],
}
if sub.EndDate != "" {
cs.end = domain.ParseDateOr(sub.EndDate, today)
@@ -173,6 +391,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
list = append(list, cs)
}
back := r.URL.RequestURI()
data := &calendarData{
Year: y, Month: m,
Title: fmt.Sprintf("%d 年 %d 月", y, m),
@@ -180,7 +399,13 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Selected: sel,
SelectedIsToday: sel == domain.Date(today),
Back: r.URL.RequestURI(),
Back: back,
PkgID: pkgID,
Tag: tag,
FilterQuery: "pkg=" + itoa(pkgID),
}
if tag != "" {
data.FilterQuery = "tag=" + url.QueryEscape(tag)
}
data.PrevY, data.PrevM = m-1, y
if data.PrevM < 1 {
@@ -192,8 +417,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
data.NextM = 1
data.NextY = y + 1
}
label := selT.Format("1 月 2 日")
data.SelectedLabel = label + " 周" + dowCN(sel)
data.SelectedLabel = selT.Format("1 月 2 日") + " 周" + dowCN(sel)
data.SelectedIsPast = selT.Before(today)
// 铺格子
@@ -216,20 +440,43 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if cs.sub.EndDate == ds {
cell.DueCount++
}
if !cell.InMonth || !cs.covers(ds) {
if !cell.InMonth {
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) {
continue
}
cell.TaskCount++
ci := checkins[cs.sub.ID][ds]
isUrgent := urgent[cs.sub.ID][ds]
if isUrgent {
cell.UrgentCnt++
}
t := Ticket{
SubID: cs.sub.ID,
Tail: cs.sub.CustomerTail,
Name: cs.sub.CustomerName,
Pkg: shortName(cs.sub.PackageName),
Color: cs.sub.PackageColor,
Done: ci.Status == "done",
Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds,
SubID: cs.sub.ID,
Tail: cs.sub.CustomerTail,
Name: cs.sub.CustomerName,
Pkg: shortName(cs.sub.PackageName),
Color: cs.sub.PackageColor,
Done: ci.Status == "done",
Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent,
AssigneeColor: cs.sub.AssigneeColor,
}
if t.Done || t.Skipped {
cell.DoneCount++
@@ -237,21 +484,28 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
tickets = append(tickets, t)
}
sort.SliceStable(tickets, func(i, j int) bool {
if tickets[i].Urgent != tickets[j].Urgent {
return tickets[i].Urgent
}
if tickets[i].Done != tickets[j].Done {
return !tickets[i].Done
}
return tickets[i].SubID < tickets[j].SubID
})
// 竖条按“当天全部任务”算,不能被色签的 3 条上限截断
cell.Strip = assigneeStrip(tickets)
if len(tickets) > 3 {
cell.More = len(tickets) - 3
tickets = tickets[:3]
}
cell.Tickets = tickets
cell.PendingCount = cell.TaskCount - cell.DoneCount
cell.Heat = heatLevel(cell.TaskCount + cell.DueCount)
if cell.InMonth {
data.MonthDue += cell.DueCount
data.MonthTasks += cell.TaskCount
data.MonthDone += cell.DoneCount
data.MonthUrgent += cell.UrgentCnt
}
week = append(week, cell)
}
@@ -265,11 +519,25 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if cs.sub.EndDate == sel {
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) {
continue
}
v := newSubView(cs.sub, today, warn)
row := TaskRow{SubView: v}
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"
@@ -279,15 +547,13 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
}
tasks = append(tasks, row)
}
sort.SliceStable(tasks, func(i, j int) bool {
ai := tasks[i].Done || tasks[i].Skipped
aj := tasks[j].Done || tasks[j].Skipped
if ai != aj {
return !ai
}
return tasks[i].EndDate < tasks[j].EndDate
})
sortTasks(tasks)
data.Tasks = tasks
for _, row := range tasks {
if !row.Done && !row.Skipped {
data.TaskPending++
}
}
data.Due = due
// 其它日期到期(不在当天的 7 天内)
@@ -296,25 +562,62 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if u.EndDate == sel {
continue
}
if pkgID > 0 && u.PackageID != pkgID {
continue
}
if pkgID <= 0 && tag != "" {
if p, err := s.db.Package(u.PackageID); err != nil || p.Tag != tag {
continue
}
}
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
}
}
// 页签:先按「套餐标签」合并,再给勾了独立显示的套餐各开一个
pkgs, _ := s.db.ListPackages(true)
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, subs, len(list))
for i := range data.Tabs {
t := &data.Tabs[i]
href := fmt.Sprintf("/calendar?y=%d&m=%d&d=%s", y, m, sel)
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 tail := r.URL.Query().Get("pick"); tail != "" {
data.PickTail = tail
for _, row := range tasks {
if row.CustomerTail == tail {
data.PickRows = append(data.PickRows, row)
}
}
if len(data.PickRows) == 0 {
data.PickMsg = "尾号 " + tail + " 在今天没有待完成的服务"
}
}
// 追加面板
if eid := s.queryInt(r, "extend", 0); eid > 0 {
if sub, err := s.db.Sub(int64(eid)); err == nil {
if pkg, err := s.db.Package(sub.PackageID); err == nil {
back := r.URL.Path + "?" + r.URL.RawQuery
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
}
}
}
// 空态提示
if n, _ := s.db.CustomerCount(); n == 0 {
data.NoCustomers = true
}
if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 {
if len(pkgs) == 0 {
data.NoPackages = true
}
@@ -323,6 +626,86 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "calendar", p)
}
// sortTasks 加急优先,其次未完成,最后按结束日。
func sortTasks(tasks []TaskRow) {
sort.SliceStable(tasks, func(i, j int) bool {
ai, aj := tasks[i], tasks[j]
if ai.Urgent != aj.Urgent {
return ai.Urgent
}
adi := ai.Done || ai.Skipped
adj := aj.Done || aj.Skipped
if adi != adj {
return !adi
}
return ai.EndDate < aj.EndDate
})
}
// 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 {
switch {
case n <= 0:
@@ -346,3 +729,15 @@ func shortName(s string) string {
}
return string(r[:2])
}
// stripParam 去掉地址里的某个参数,用于拼“返回”链接。
func stripParam(raw, key string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
q := u.Query()
q.Del(key)
u.RawQuery = q.Encode()
return u.String()
}
+242 -18
View File
@@ -2,22 +2,26 @@ package web
import (
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"daili/internal/domain"
"daili/internal/store"
)
type customersData struct {
List []store.Customer
Pager Pager
Q string
Total int
PackageCount int
NoPackages bool
List []store.Customer
Pager Pager
Q string
Total int
PackageCount int
NoPackages bool
ShowArchived bool
ArchivedCount int
}
func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
@@ -28,21 +32,24 @@ func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
if pageNo < 1 {
pageNo = 1
}
list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn)
showArchived := r.URL.Query().Get("archived") == "1"
list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn, showArchived)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
pager := newPager(r, pageNo, pageSize, total)
if pager.Page != pageNo {
list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn)
list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn, showArchived)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
}
pkgs, _ := s.db.ListPackages(false)
data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0}
archivedN, _ := s.db.CountArchivedCustomers()
data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0,
ShowArchived: showArchived, ArchivedCount: archivedN}
p := s.newPage(r, "客户", "customers")
p.D = data
s.render(w, r, "customers", p)
@@ -51,15 +58,17 @@ func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
type customerFormData struct {
Customer *store.Customer
IsNew bool
Users []store.User
}
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers")
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
users, _ := s.db.ActiveUsers()
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true, Users: users}
s.render(w, r, "customer_form", p)
}
func readCustomerForm(r *http.Request) *store.Customer {
func (s *Server) readCustomerForm(r *http.Request) *store.Customer {
return &store.Customer{
Tail: strings.TrimSpace(r.PostFormValue("tail")),
Name: strings.TrimSpace(r.PostFormValue("name")),
@@ -69,6 +78,7 @@ func readCustomerForm(r *http.Request) *store.Customer {
Tags: normalizeTags(r.PostFormValue("tags")),
Source: strings.TrimSpace(r.PostFormValue("source")),
Note: strings.TrimSpace(r.PostFormValue("note")),
AssigneeID: int64(s.formInt(r, "assignee_id", 0)),
}
}
@@ -104,10 +114,11 @@ func validateCustomer(c *store.Customer) string {
}
func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
c := readCustomerForm(r)
c := s.readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" {
p.D = &customerFormData{Customer: c, IsNew: true}
users, _ := s.db.ActiveUsers()
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p)
return
}
@@ -117,7 +128,8 @@ func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
}
p.D = &customerFormData{Customer: c, IsNew: true}
users, _ := s.db.ActiveUsers()
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p)
return
}
@@ -143,7 +155,7 @@ func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
c := readCustomerForm(r)
c := s.readCustomerForm(r)
c.ID = id
p := s.newPage(r, "编辑客户", "customers")
if msg := validateCustomer(c); msg != "" {
@@ -179,11 +191,113 @@ type customerDetailData struct {
AdjSubColor map[int64]string
Checkins []CheckinRow
Packages []store.Package
Users []store.User
CustCal *custCal
Timeline *Timeline
Extend *ExtendPanel
TotalRemain string
}
// custCalDay 客户月历的一格。
type custCalDay struct {
Date string
Day int
InMonth bool
IsToday bool
Count int
Done int
AllDone bool
}
// custCal 客户详情页的当月排单日历。
type custCal struct {
Year, Month int
Title string
Prev, Next string
Weekdays []string
Weeks [][]custCalDay
TaskDays int
TotalTasks int
}
func (s *Server) buildCustomerCal(c *store.Customer, cm string, today time.Time) *custCal {
y, m := today.Year(), int(today.Month())
if t, err := time.Parse("2006-01", cm); err == nil {
y, m = t.Year(), int(t.Month())
}
monthStart := domain.MonthStart(y, time.Month(m))
monthEnd := domain.MonthEnd(y, time.Month(m))
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
subs, err := s.db.SubsByCustomer(c.ID)
if err != nil {
return nil
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, _ := s.db.PausesForSubs(ids)
checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1)))
warn := s.warnDays()
list := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn), checkins: checkins[sub.ID]}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
list = append(list, cs)
}
cal := &custCal{
Year: y, Month: m,
Title: fmt.Sprintf("%d 年 %d 月", y, m),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
}
py, pm := y, m-1
if pm < 1 {
pm = 12
py--
}
ny, nm := y, m+1
if nm > 12 {
nm = 1
ny++
}
cal.Prev = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, py, pm)
cal.Next = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, ny, nm)
for wi := 0; wi < weeks; wi++ {
week := make([]custCalDay, 0, 7)
for di := 0; di < 7; di++ {
d := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(d)
cell := custCalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(m), IsToday: ds == domain.Date(today)}
for _, cs := range list {
if !cs.covers(ds) {
continue
}
cell.Count++
if ci, ok := checkins[cs.sub.ID][ds]; ok && (ci.Status == "done" || ci.Status == "skipped") {
cell.Done++
}
}
if cell.Count > 0 {
cell.AllDone = cell.Done >= cell.Count
if cell.InMonth {
cal.TaskDays++
cal.TotalTasks += cell.Count
}
}
week = append(week, cell)
}
cal.Weeks = append(cal.Weeks, week)
}
return cal
}
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
c, err := s.db.Customer(id)
@@ -208,10 +322,33 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
}
pauses, _ := s.db.PausesForSubs(ids)
users, _ := s.db.ActiveUsers()
// 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加
owned := map[int64]bool{}
for _, sub := range subs {
owned[sub.PackageID] = true
}
available := make([]store.Package, 0, len(pkgs))
for _, pkg := range pkgs {
if !owned[pkg.ID] {
available = append(available, pkg)
}
}
sorted := append([]store.Subscription{}, subs...)
sort.SliceStable(sorted, func(i, j int) bool {
ai := sorted[i].Status == domain.StatusClosed
aj := sorted[j].Status == domain.StatusClosed
if ai != aj {
return !ai
}
return sorted[i].ID < sorted[j].ID
})
data := &customerDetailData{
Users: users,
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
Customer: c,
Subs: subViews(subs, today, warn),
Packages: pkgs,
Subs: subViews(sorted, today, warn),
Packages: available,
Timeline: buildTimeline(subs, pauses, today),
AdjSubName: map[int64]string{},
AdjSubColor: map[int64]string{},
@@ -247,6 +384,83 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "customer_detail", p)
}
// handleCustomerArchive 停用客户:默认从列表、日历、大屏隐藏,订阅保留。
func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
c, err := s.db.Customer(id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.db.SetCustomerArchived(id, true); err != nil {
s.redirect(w, r, "/customers/"+itoa(id), "停用失败:"+err.Error())
return
}
s.logOp(r, "停用客户", "customer", id, c.Tail+" "+c.Name+"(订阅保留,不再上日历)")
s.redirect(w, r, "/customers", "已停用客户 "+c.Tail+" "+c.Name+",可在「已停用」里恢复")
}
// handleCustomerRestore 恢复客户,订单一并回到日历。
func (s *Server) handleCustomerRestore(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
c, err := s.db.Customer(id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.db.SetCustomerArchived(id, false); err != nil {
s.redirect(w, r, "/customers/"+itoa(id), "恢复失败:"+err.Error())
return
}
s.logOp(r, "恢复客户", "customer", id, c.Tail+" "+c.Name)
s.redirect(w, r, "/customers/"+itoa(id), "已恢复客户 "+c.Tail+" "+c.Name)
}
// handleCustomerDelete 彻底删除客户及其订阅/打卡/追加记录(外键级联)。
func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
c, err := s.db.Customer(id)
if err != nil {
http.NotFound(w, r)
return
}
if r.PostFormValue("confirm") != "delete" {
s.redirect(w, r, "/customers/"+itoa(id), "请在弹窗里确认删除")
return
}
if err := s.db.DeleteCustomer(id); err != nil {
s.redirect(w, r, "/customers/"+itoa(id), "删除失败:"+err.Error())
return
}
s.logOp(r, "删除客户", "customer", id, c.Tail+" "+c.Name+"(含订阅与打卡记录)")
s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name)
}
// handleCustomerAssignee 从客户详情快速指派/取消打手。
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) {
customerID := pathID(r)
c, err := s.db.Customer(customerID)
@@ -273,7 +487,7 @@ func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
return
}
s.logOp(r, "新建订阅", "subscription", sub.ID,
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+unitLabel(sub.Unit)+")")
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+subTypeLabel(sub)+")")
s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」")
}
@@ -284,6 +498,16 @@ func unitLabel(u string) string {
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 sortAdjustments(list []store.Adjustment) {
+115
View File
@@ -2,6 +2,7 @@ package web
import (
"encoding/csv"
"errors"
"fmt"
"net"
"net/http"
@@ -55,6 +56,7 @@ type settingsData struct {
BackupEnabled bool
ConfigPath string
ScreenPublic bool
PortalPublic bool
Addrs []string
Port string
Uptime string
@@ -139,6 +141,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
BackupEnabled: s.cfg.BackupEnabled,
ConfigPath: s.cfg.ConfigPath,
ScreenPublic: s.cfg.ScreenPublic,
PortalPublic: s.cfg.PortalPublic,
Addrs: LocalAddrs(),
Port: port,
Uptime: humanDuration(time.Since(s.started)),
@@ -152,6 +155,118 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
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) {
warn := s.formInt(r, "warn_days", 3)
if warn < 0 {
+158 -6
View File
@@ -1,6 +1,7 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
@@ -27,13 +28,16 @@ func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
type packageFormData struct {
Package *store.Package
IsNew bool
Tags []string
}
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新增套餐", "packages")
tags, _ := s.db.PackageTags()
p.D = &packageFormData{
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#3B6EA5"},
IsNew: true,
Tags: tags,
}
s.render(w, r, "package_form", p)
}
@@ -62,10 +66,47 @@ func readPackageForm(r *http.Request) *store.Package {
if p.Unit == "" {
p.Unit = domain.UnitDay
}
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
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
}
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 == "" {
return "套餐名称不能为空"
}
@@ -84,17 +125,125 @@ func validatePackage(p *store.Package) string {
return ""
}
// packageDetailData 套餐详情页数据。
type packageDetailData struct {
Package *store.Package
Customers []store.Customer
Subscribed map[int64]bool
Q string
Tags []string
Today string
}
// handlePackageDetail 套餐详情:查看信息 + 勾选客户批量开通。
func (s *Server) handlePackageDetail(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
today := domain.Today()
warn := s.warnDays()
q := strings.TrimSpace(r.URL.Query().Get("q"))
list, _, err := s.db.ListCustomers(q, 300, 0, domain.Date(today), warn, false)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
subscribed, err := s.db.SubscribedCustomerIDs(id)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
tags, _ := s.db.PackageTags()
p := s.newPage(r, "套餐详情", "packages")
p.D = &packageDetailData{Package: pkg, Customers: list, Subscribed: subscribed, Q: q, Tags: tags, Today: domain.Date(today)}
s.render(w, r, "package_detail", p)
}
// handlePackageBatchSubscribe 给勾选的客户批量开通该套餐。
func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
back := "/packages/" + itoa(id)
raw := r.PostForm["customer_ids"]
if len(raw) == 0 {
s.redirect(w, r, back, "没有选中任何客户")
return
}
start := strings.TrimSpace(r.PostFormValue("start_date"))
if start == "" && !pkg.Season {
start = domain.Date(domain.Today())
}
if start != "" {
if _, err := domain.ParseDate(start); err != nil {
s.redirect(w, r, back, "开始日期格式不正确")
return
}
}
end := strings.TrimSpace(r.PostFormValue("end_date"))
days := s.formInt(r, "days", 0)
count := s.formInt(r, "total_count", 0)
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
done, skipped, failed := 0, 0, 0
var lastErr string
for _, v := range raw {
cid, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
failed++
continue
}
_, err = s.db.CreateSub(store.CreateSubInput{
CustomerID: cid, PackageID: id, StartDate: start, EndDate: end,
Days: days, TotalCount: count, OperatorID: op,
})
if err != nil {
if strings.Contains(err.Error(), "已订阅") {
skipped++
continue
}
failed++
lastErr = err.Error()
continue
}
done++
}
s.logOp(r, "批量开通", "package", id,
fmt.Sprintf("%s:成功 %d、跳过 %d、失败 %d", pkg.Name, done, skipped, failed))
msg := fmt.Sprintf("已为 %d 位客户开通「%s」", done, pkg.Name)
if skipped > 0 {
msg += fmt.Sprintf(",%d 位已有该套餐已跳过", skipped)
}
if failed > 0 {
msg += fmt.Sprintf(",%d 位失败(%s)", failed, lastErr)
}
s.redirect(w, r, back, msg)
}
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
pkg := readPackageForm(r)
p := s.newPage(r, "新增套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
p.D = &packageFormData{Package: pkg, IsNew: true}
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
s.fail(w, r, "package_form", msg, p)
return
}
id, err := s.db.CreatePackage(pkg)
if err != nil {
p.D = &packageFormData{Package: pkg, IsNew: true}
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
@@ -110,7 +259,8 @@ func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
return
}
p := s.newPage(r, "编辑套餐", "packages")
p.D = &packageFormData{Package: pkg, IsNew: false}
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.render(w, r, "package_form", p)
}
@@ -120,12 +270,14 @@ func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
pkg.ID = id
p := s.newPage(r, "编辑套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
p.D = &packageFormData{Package: pkg, IsNew: false}
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.fail(w, r, "package_form", msg, p)
return
}
if err := s.db.UpdatePackage(pkg); err != nil {
p.D = &packageFormData{Package: pkg, IsNew: false}
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
+390
View File
@@ -0,0 +1,390 @@
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()
}
+259 -67
View File
@@ -1,9 +1,11 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"time"
"net/url"
"strings"
"daili/internal/domain"
"daili/internal/store"
@@ -15,120 +17,310 @@ type screenDay struct {
InMonth bool
IsToday bool
IsPast bool
Due int
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 []SubView
Recent []store.Adjustment
TodayTasks int
TodayDone int
Refresh int
Version string
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),
DueToday: subViews(stats.DueTodaySubs, today, warn),
Refresh: s.screenRefresh(),
Version: s.cfg.Version,
}
if len(data.DueToday) > 6 {
data.DueToday = data.DueToday[:6]
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)
due := stats.Heat[ds]
week = append(week, screenDay{
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),
Due: due,
Heat: heatLevel(due),
})
}
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
}
list, done := s.todayTaskList(today)
data.TodayTasks = len(list)
data.TodayDone = done
data.TodayList = subViews(list, today, warn)
// 页签:按标签合并 + 独立显示另开;通行证 / 深渊也放进大屏,用整段色块显示周期状态
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)
}
// todayTaskList 返回今天应服务的订阅与已完成数量。
func (s *Server) todayTaskList(today time.Time) ([]store.Subscription, int) {
ts := domain.Date(today)
subs, err := s.db.CalendarSubs(ts, ts)
// 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 {
return nil, 0
http.Error(w, "读取失败", http.StatusInternalServerError)
return
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
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"`
}
pauses, _ := s.db.PausesForSubs(ids)
checkins, _ := s.db.CheckinsBetween(ts, ts)
warn := s.warnDays()
out := []store.Subscription{}
done := 0
for _, sub := range subs {
if sub.Status == domain.StatusClosed {
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
}
if sub.StartDate > ts || sub.EndDate == "" || sub.EndDate < ts {
continue
}
cs := calSub{sub: sub, pauses: pauses[sub.ID], hasEnd: true, eval: sub.Eval(today, warn)}
if sub.Unit == domain.UnitCount && cs.eval.RemainingCount <= 0 {
continue
}
if cs.pausedOn(ts) {
continue
}
out = append(out, sub)
if ci, ok := checkins[sub.ID][ts]; ok && ci.Status == "done" {
done++
}
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(),
})
}
return out, done
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(out)
}
+62
View File
@@ -72,6 +72,15 @@ func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
note := strings.TrimSpace(r.PostFormValue("note"))
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"))
plan, err := s.db.ExtendDays(id, newEnd, note, opID)
@@ -160,6 +169,19 @@ func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
if u != nil {
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 {
s.redirect(w, r, back, "打卡失败:"+err.Error())
return
@@ -183,3 +205,43 @@ func (s *Server) handleCheckinDelete(w http.ResponseWriter, r *http.Request) {
s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id))
s.redirect(w, r, back, "已撤销打卡")
}
// handleSubDelete 彻底删除订阅。
func (s *Server) handleSubDelete(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
if r.PostFormValue("confirm") != "delete" {
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "请在弹窗里确认删除")
return
}
if err := s.db.DeleteSubscription(id); err != nil {
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "删除失败:"+err.Error())
return
}
s.logOp(r, "删除订阅", "subscription", id, sub.SubTitle()+"(含打卡与追加记录)")
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), "已把通行证标记为完成")
}
+231
View File
@@ -0,0 +1,231 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"daili/internal/domain"
)
// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余),
// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。
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)
if err != nil {
return nil, err
}
today := domain.Today()
warn := s.warnDays()
rows := []TaskRow{}
for _, sub := range subs {
if customerIDs != nil && !customerIDs[sub.CustomerID] {
continue
}
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn), checkins: checkins[sub.ID]}
if sub.EndDate != "" {
cs.hasEnd = true
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) {
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)
}
sortTasks(rows)
return rows, nil
}
// completeTask 完成某个订阅的某一天:通行证走整段完成,其余写打卡。
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)
}
// handleQuickCheckin 输入手机尾号直接完成当日服务:
// 命中 1 项直接完成;命中多项则跳到日历的选择面板。
func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
tail := strings.TrimSpace(r.PostFormValue("tail"))
date := strings.TrimSpace(r.PostFormValue("date"))
pkgID := int64(s.formInt(r, "pkg", 0))
tag := strings.TrimSpace(r.PostFormValue("tag"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
back := backURL(r, "/calendar?d="+date)
if tail == "" {
s.redirect(w, r, back, "请输入手机尾号")
return
}
customers, err := s.db.CustomersByTail(tail)
if err != nil {
s.redirect(w, r, back, "查询客户失败:"+err.Error())
return
}
if len(customers) == 0 {
s.redirect(w, r, back, "没有找到尾号 "+tail+" 的客户")
return
}
ids := map[int64]bool{}
for _, c := range customers {
ids[c.ID] = true
}
rows, err := s.pendingTasks(date, ids, pkgID, tag)
if err != nil {
s.redirect(w, r, back, "读取待完成失败:"+err.Error())
return
}
pending := make([]TaskRow, 0, len(rows))
for _, row := range rows {
if !row.Done && !row.Skipped {
pending = append(pending, row)
}
}
switch len(pending) {
case 0:
if len(rows) > 0 {
s.redirect(w, r, back, "尾号 "+tail+" 今天的服务已经处理过了")
return
}
s.redirect(w, r, back, "尾号 "+tail+" 今天没有待完成的服务")
return
case 1:
row := pending[0]
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
if err := s.completeTask(row.ID, date, op); err != nil {
s.redirect(w, r, back, "完成失败:"+err.Error())
return
}
s.logOp(r, "快速完成", "subscription", row.ID,
fmt.Sprintf("尾号 %s:%s %s 完成 %s(%s)", tail, row.CustomerTail, row.CustomerName, row.PackageName, date))
s.redirect(w, r, back, "已为 "+row.CustomerTail+" "+row.CustomerName+" 完成「"+row.PackageName+"」")
return
default:
// 多项:回到日历并打开选择面板
target := stripParam(back, "pick")
sep := "?"
if strings.Contains(target, "?") {
sep = "&"
}
s.redirect(w, r, target+sep+"pick="+urlQueryEscape(tail), fmt.Sprintf("尾号 %s 有 %d 项待完成,请勾选", tail, len(pending)))
return
}
}
// handleCheckinBatch 批量完成选中的订阅(同日)。
func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) {
date := strings.TrimSpace(r.PostFormValue("date"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
back := backURL(r, "/calendar?d="+date)
_ = r.ParseForm()
raw := r.PostForm["sub_ids"]
if len(raw) == 0 {
s.redirect(w, r, back, "没有选中任何项")
return
}
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
done, failed := 0, 0
var lastErr string
for _, v := range raw {
id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
failed++
continue
}
if err := s.completeTask(id, date, op); err != nil {
failed++
lastErr = err.Error()
continue
}
done++
}
if done > 0 {
s.logOp(r, "批量完成", "checkin", 0, fmt.Sprintf("%s 一次完成 %d 项", date, done))
}
msg := fmt.Sprintf("已完成 %d 项", done)
if failed > 0 {
msg += fmt.Sprintf(",%d 项失败(%s)", failed, lastErr)
}
s.redirect(w, r, stripParam(back, "pick"), msg)
}
// handleUrgentToggle 勾选/取消某天某订阅的加急。
func (s *Server) handleUrgentToggle(w http.ResponseWriter, r *http.Request) {
subID := int64(s.formInt(r, "sub_id", 0))
date := strings.TrimSpace(r.PostFormValue("date"))
urgent := r.PostFormValue("urgent") == "1"
back := backURL(r, "/calendar")
if subID <= 0 {
s.redirect(w, r, back, "参数不正确")
return
}
if err := s.db.SetUrgent(subID, date, urgent); err != nil {
s.redirect(w, r, back, "设置加急失败:"+err.Error())
return
}
word := "已取消加急"
if urgent {
word = "已标记加急"
}
if sub, err := s.db.Sub(subID); err == nil {
s.logOp(r, word, "subscription", subID, sub.SubTitle()+" "+date+" "+word)
}
s.redirect(w, r, back, word)
}
func urlQueryEscape(v string) string {
var b strings.Builder
for _, c := range []byte(v) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteString("%")
const hex = "0123456789ABCDEF"
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
return b.String()
}
+100 -24
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
@@ -31,6 +32,7 @@ var staticFS embed.FS
const (
cookieSession = "daili_sid"
cookieFlash = "daili_flash"
cookieTheme = "daili_theme"
sessionTTL = 7 * 24 * time.Hour
pageSize = 20
)
@@ -43,17 +45,19 @@ type Config struct {
BackupDir string
BackupEnabled bool
ScreenPublic bool
PortalPublic bool
ConfigPath string
Version string
}
// Server 是应用服务。
type Server struct {
db *store.DB
cfg Config
tpl *renderer
handler http.Handler
started time.Time
db *store.DB
cfg Config
tpl *renderer
handler http.Handler
started time.Time
assetVersion string
}
type ctxKey int
@@ -66,11 +70,33 @@ func New(db *store.DB, cfg Config) (*Server, error) {
if err != nil {
return nil, err
}
s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now()}
s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now(), assetVersion: staticAssetVersion()}
s.handler = s.routes()
return s, nil
}
// staticAssetVersion 计算内嵌静态资源的内容指纹。
// 资源 URL 带上它之后可以放心长缓存;改了 CSS/JS 重新编译就会自动换 URL。
func staticAssetVersion() string {
h := sha256.New()
if err := fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
data, rerr := fs.ReadFile(staticFS, path)
if rerr != nil {
return nil
}
h.Write([]byte(path))
h.Write([]byte{0})
h.Write(data)
return nil
}); err != nil {
return "dev"
}
return hex.EncodeToString(h.Sum(nil))[:12]
}
// Handler 返回 HTTP 处理器。
func (s *Server) Handler() http.Handler { return s.handler }
@@ -81,7 +107,7 @@ func (s *Server) routes() http.Handler {
if err != nil {
panic(err)
}
mux.Handle("GET /static/", cacheControl(http.StripPrefix("/static/", http.FileServerFS(sub))))
mux.Handle("GET /static/", s.staticFiles(http.StripPrefix("/static/", http.FileServerFS(sub))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
@@ -103,6 +129,10 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
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}/assignee", s.auth(s.handleCustomerAssignee))
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}/delete", s.auth(s.handleCustomerDelete))
mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
@@ -110,35 +140,58 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume")))
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}/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 /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
mux.HandleFunc("POST /checkins/quick", s.auth(s.handleQuickCheckin))
mux.HandleFunc("POST /tasks/urgent", s.auth(s.handleUrgentToggle))
mux.HandleFunc("GET /packages", s.auth(s.handlePackages))
mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage))
mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate))
mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail))
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)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
}
// 大屏免登录:仅局域网部署时使用,内容为只读看板
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 /settings", s.auth(s.handleSettings))
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
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("GET /export/{kind}", s.auth(s.handleExport))
return s.noCacheHTML(mux)
}
func cacheControl(h http.Handler) http.Handler {
// staticFiles 给内嵌静态资源加缓存策略:
// 只有带当前内容指纹(?v=)的 URL 才允许一年不可变缓存,其它一律回源。
func (s *Server) staticFiles(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=300")
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.URL.Query().Get("v") == s.assetVersion {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
h.ServeHTTP(w, r)
})
}
@@ -320,12 +373,19 @@ type page struct {
TodayStr string
WarnDays int
Version string
Query string
Next string
DueToday int
Due3 int
Addr string
D any
// AssetVersion 是内嵌静态资源的内容指纹,用于 /static/*.css?v=... 的长缓存
AssetVersion string
Query string
Name string
InlineError string
InvalidField string
Next string
DueToday int
Due3 int
Due7 int
Addr string
Theme string // "" = 跟随系统;light / dark
D any
}
func (s *Server) warnDays() int {
@@ -355,17 +415,33 @@ func (s *Server) newPage(r *http.Request, title, nav string) *page {
TodayStr: domain.Date(today),
WarnDays: s.warnDays(),
Version: s.cfg.Version,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
AssetVersion: s.assetVersion,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
Theme: themeFromRequest(r),
}
if u != nil {
if due, due3, err := s.db.Badges(today); err == nil {
p.DueToday, p.Due3 = due, due3
if due, due3, due7, err := s.db.Badges(today); err == nil {
p.DueToday, p.Due3, p.Due7 = due, due3, due7
}
}
return p
}
// themeFromRequest 读取主题偏好:"" = 跟随系统,light / dark = 手动指定。
func themeFromRequest(r *http.Request) string {
c, err := r.Cookie(cookieTheme)
if err != nil {
return ""
}
switch c.Value {
case "light", "dark":
return c.Value
}
return ""
}
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(cookieFlash)
if err != nil || c.Value == "" {
+1239 -541
View File
File diff suppressed because it is too large Load Diff
+276 -20
View File
@@ -2,15 +2,22 @@
(function () {
'use strict';
/* 提示条自动淡出 */
/* ---------- 工具:本地日期,禁止用 toISOString 做“自然日”计算 ---------- */
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
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); }
/* ---------- 提示条:自动淡出(.flash 固定定位,不会顶动页面) ---------- */
document.querySelectorAll('[data-flash]').forEach(function (el) {
setTimeout(function () {
el.classList.add('hide');
setTimeout(function () { el.remove(); }, 450);
setTimeout(function () { el.remove(); }, 400);
}, 4200);
});
/* 按 / 聚焦搜索框 */
/* ---------- 按 / 聚焦搜索框 ---------- */
document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (document.activeElement && document.activeElement.tagName) || '';
@@ -19,17 +26,14 @@
if (s) { e.preventDefault(); s.focus(); s.select(); }
});
/* 危险操作确认 */
/* ---------- 危险操作确认 ---------- */
document.querySelectorAll('form[data-confirm]').forEach(function (f) {
f.addEventListener('submit', function (e) {
if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
});
});
/* 追加时长面板:点日历挑终止日 + 实时反算 */
function parseDay(s) { return new Date(s + 'T00:00:00'); }
function diffDays(a, b) { return Math.round((b.getTime() - a.getTime()) / 86400000); }
/* ---------- 追加时长面板:点日历挑终止日 + 实时反算 ---------- */
document.querySelectorAll('form[data-extend]').forEach(function (form) {
var base = parseDay(form.getAttribute('data-base'));
var today = parseDay(form.getAttribute('data-today'));
@@ -63,46 +67,298 @@
if (input.value) paint(input.value);
});
/* 新订阅表单:按套餐类型切换「天数 / 次数 / 终止日」 */
/* ---------- 新订阅表单:按套餐类型切换「天数 / 次数 / 终止日」 ---------- */
document.querySelectorAll('form[data-sub-form]').forEach(function (form) {
var sel = form.querySelector('[data-pkg-select]');
var daysWrap = form.querySelector('[data-days-wrap]');
var countWrap = form.querySelector('[data-count-wrap]');
var endWrap = form.querySelector('[data-end-wrap]');
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;
/* 只覆盖“上一次自动填的”终止日,不碰用户手填的值 */
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 unit = opt && opt.getAttribute('data-unit');
var amount = opt && opt.getAttribute('data-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') {
if (daysWrap) daysWrap.hidden = true;
if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; }
if (endWrap) endWrap.hidden = true;
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') {
if (daysWrap) { daysWrap.hidden = false; if (amount) daysWrap.querySelector('input').value = amount; }
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = false;
setWrap(daysWrap, true);
setWrap(countWrap, false);
setWrap(endWrap, true);
if (amount) daysWrap.querySelector('input').value = amount;
} else {
if (daysWrap) daysWrap.hidden = false;
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = true;
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' ? '次' : '天';
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();
});
/* ---------- 批量开通:表头全选 + 半选状态 ---------- */
document.querySelectorAll('[data-select-all]').forEach(function (box) {
var form = box.closest('form') || document;
var boxes = form.querySelectorAll('input[name="customer_ids"]');
function refresh() {
var total = 0, checked = 0;
boxes.forEach(function (c) {
if (c.disabled) return;
total++;
if (c.checked) checked++;
});
box.checked = total > 0 && checked === total;
box.indeterminate = checked > 0 && checked < total;
}
box.addEventListener('change', function () {
boxes.forEach(function (c) { if (!c.disabled) c.checked = box.checked; });
box.indeterminate = false;
});
boxes.forEach(function (c) { c.addEventListener('change', refresh); });
refresh();
});
})();
/* ---------- 亮色 / 暗色切换 ----------
唯一状态源是 <html data-bs-theme>:服务端按 Cookie 直出,
没有 Cookie 时由 <head> 里的内联脚本按系统偏好补上。 */
(function () {
'use strict';
var KEY = 'daili_theme';
var root = document.documentElement;
var btns = document.querySelectorAll('[data-theme-toggle]');
if (!btns.length) return;
function current() {
return root.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light';
}
function paint() {
var dark = current() === 'dark';
btns.forEach(function (b) { b.setAttribute('aria-pressed', dark ? 'true' : 'false'); });
}
btns.forEach(function (btn) {
btn.addEventListener('click', function () {
var next = current() === 'dark' ? 'light' : 'dark';
root.setAttribute('data-bs-theme', next);
document.cookie = KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax';
paint();
});
});
paint();
})();
/* ---------- 触摸小键盘 + 抽屉时钟 + 更多菜单 ---------- */
(function () {
'use strict';
/* 时钟:精确到秒 */
var clocks = document.querySelectorAll('[data-clock]');
function tick() {
var d = new Date();
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
var t = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
clocks.forEach(function (el) { el.textContent = t; });
}
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.querySelectorAll('.more-actions[open]').forEach(function (d) {
if (!d.contains(e.target)) { d.removeAttribute('open'); }
});
});
})();
/* ---------- 触控/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(); }
}
});
});
})();
+359 -158
View File
@@ -1,212 +1,413 @@
/* ============================================================
代打台 · 可视化大屏(深色「记分板」)
代打台 · 可视化大屏(v1.10)
------------------------------------------------------------
只认 <html data-bs-theme="light|dark">;默认由模板给 dark。
Bootstrap 只用于重置与栅格,视觉全部在本文件内闭环。
============================================================ */
:root {
--bg: #070a0f;
--panel: #0e141c;
--panel-2: #121a24;
--line: #1b2530;
--ink: #e8eef2;
--dim: #7d8b99;
--mint: #35e0c6;
--mint-dim: rgba(53, 224, 198, 0.14);
--amber: #f5a524;
--red: #f2555a;
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
--sans: "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
: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 { margin: 0; height: 100%; }
html, body { height: 100%; margin: 0; }
body.screen-body {
background:
radial-gradient(1200px 600px at 15% -10%, rgba(53, 224, 198, 0.07), transparent 60%),
radial-gradient(900px 500px at 100% 0%, rgba(245, 165, 36, 0.05), transparent 55%),
var(--bg);
color: var(--ink);
font-family: var(--sans);
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;
min-height: 100vh;
overflow-x: hidden;
line-height: 1.45;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
a { color: var(--mint); text-decoration: none; }
a:hover { opacity: 0.8; }
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 {
display: flex; flex-direction: column;
min-height: 100vh;
padding: clamp(12px, 1.4vw, 22px) clamp(14px, 1.8vw, 28px);
gap: clamp(10px, 1.1vw, 16px);
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: 16px; }
.scr-brand {
display: flex; align-items: center; gap: 10px;
font-size: clamp(15px, 1.2vw, 19px); font-weight: 700; letter-spacing: 0.1em;
}
/* ---------- 头部 ---------- */
.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: grid; place-items: center;
width: 30px; height: 30px; border-radius: 3px;
background: var(--mint); color: #04211d; font-size: 15px;
box-shadow: 0 0 22px rgba(53, 224, 198, 0.45);
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; color: var(--dim); font-weight: 400; letter-spacing: 0.24em; font-size: 0.7em; }
.scr-brand em { font-style: normal; font-size: 11px; font-weight: 500; color: var(--scr-muted); letter-spacing: 0.16em; }
.scr-clock { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; }
.scr-clock b {
font-family: var(--mono); font-size: clamp(24px, 2.4vw, 36px);
font-weight: 600; line-height: 1;
color: var(--ink); letter-spacing: 0.04em;
text-shadow: 0 0 22px rgba(53, 224, 198, 0.25);
.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-clock span { font-family: var(--mono); font-size: 11px; color: var(--dim); letter-spacing: 0.1em; }
.scr-links { font-size: 11px; color: var(--dim); }
.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; }
/* KPI */
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(8px, 0.9vw, 14px); }
.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; overflow: hidden;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px) clamp(12px, 1.2vw, 20px);
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::before {
.kpi::after {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--mint); opacity: 0.75;
background: var(--scr-accent);
}
.kpi-warm::before { background: var(--amber); }
.kpi-hot::before { background: var(--red); }
.kpi span { display: block; font-size: 11px; color: var(--dim); letter-spacing: 0.16em; }
.kpi b {
font-family: var(--mono); font-size: clamp(28px, 3.4vw, 52px); font-weight: 700;
line-height: 1.15; letter-spacing: -0.01em;
}
.kpi i { font-style: normal; font-size: 11px; color: var(--dim); margin-left: 4px; }
.kpi-hot b { color: var(--red); }
.kpi-warm b { color: var(--amber); }
.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; }
/* 主区 */
.scr-main { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); flex: 1 1 auto; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px);
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; margin-bottom: 10px; }
.panel-head h2 {
margin: 0; font-size: clamp(12px, 0.95vw, 14px); font-weight: 600; letter-spacing: 0.08em;
color: var(--ink);
.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::before {
content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 7px;
background: var(--mint); vertical-align: 2px;
}
.panel-head span { font-size: 11px; color: var(--dim); font-family: var(--mono); }
.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 { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; }
.heat-dow, .heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.heat-row { flex: 1 1 0; min-height: 0; }
.heat-dow { flex: 0 0 auto; }
.heat-dow span { text-align: center; font-size: 10px; color: var(--dim); }
/* 热力 */
.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; flex-direction: column; justify-content: space-between;
min-height: 34px;
padding: 5px 6px;
border: 1px solid var(--line);
border-radius: 3px;
background: #0a1017;
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: var(--mono); font-size: 10px; color: var(--dim); }
.heat-cell b {
align-self: flex-end; font-family: var(--mono); font-size: clamp(12px, 1.1vw, 16px);
color: var(--mint); font-weight: 700;
}
.heat-cell.out { opacity: 0.28; }
.heat-cell.today { border-color: var(--mint); box-shadow: 0 0 0 1px rgba(53, 224, 198, 0.35), 0 0 18px rgba(53, 224, 198, 0.16); }
.heat-cell.h1 { background: rgba(53, 224, 198, 0.08); border-color: rgba(53, 224, 198, 0.22); }
.heat-cell.h2 { background: rgba(53, 224, 198, 0.15); border-color: rgba(53, 224, 198, 0.3); }
.heat-cell.h3 { background: rgba(53, 224, 198, 0.24); border-color: rgba(53, 224, 198, 0.42); }
.heat-cell.h4 { background: rgba(53, 224, 198, 0.36); border-color: rgba(53, 224, 198, 0.6); }
.heat-cell.h3 b, .heat-cell.h4 b { color: #eafffb; }
.heat-legend { display: flex; align-items: center; gap: 5px; margin-top: 10px; font-size: 10px; color: var(--dim); flex: 0 0 auto; }
.heat-legend i { width: 18px; height: 9px; border-radius: 2px; border: 1px solid #26313d; }
.heat-legend .h0 { background: #0a1017; }
.heat-legend .h1 { background: rgba(53, 224, 198, 0.16); }
.heat-legend .h2 { background: rgba(53, 224, 198, 0.28); }
.heat-legend .h3 { background: rgba(53, 224, 198, 0.42); }
.heat-legend .h4 { background: rgba(53, 224, 198, 0.6); }
.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; }
/* TOP10 */
.top-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow: hidden; }
.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: 22px auto 1fr auto auto;
grid-template-areas: "rank tail name pkg remain" "rank bar bar bar bar";
column-gap: 8px; row-gap: 3px;
align-items: center;
padding: 5px 8px;
border-radius: 3px;
background: #0a1017;
border-left: 2px solid transparent;
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 .rank { grid-area: rank; font-family: var(--mono); font-size: 11px; color: var(--dim); }
.top-tail { grid-area: tail; font-family: var(--mono); font-size: clamp(13px, 1.15vw, 17px); font-weight: 700; letter-spacing: 0.03em; }
.top-name { grid-area: name; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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-area: pkg; font-size: 10px; color: var(--dim);
border-left: 2px solid var(--tag); padding-left: 5px;
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-area: remain; font-family: var(--mono); font-size: 12px; color: var(--mint); }
.top-bar { grid-area: bar; height: 3px; background: #16202b; border-radius: 999px; overflow: hidden; }
.top-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--mint), rgba(53, 224, 198, 0.35)); }
.top-item.lv-warn { border-left-color: var(--amber); }
.top-item.lv-warn .top-remain { color: var(--amber); }
.top-item.lv-warn .top-bar i { background: linear-gradient(90deg, var(--amber), rgba(245, 165, 36, 0.3)); }
.top-item.lv-today, .top-item.lv-expired { border-left-color: var(--red); }
.top-item.lv-today .top-remain, .top-item.lv-expired .top-remain { color: var(--red); }
.top-item.lv-today .top-bar i, .top-item.lv-expired .top-bar i { background: linear-gradient(90deg, var(--red), rgba(242, 85, 90, 0.3)); }
.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: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); }
.today-list, .recent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 22vh; overflow: hidden; }
/* ---------- 底部两块 ---------- */
.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 1fr auto; gap: 10px; align-items: center;
padding: 4px 6px; border-bottom: 1px solid rgba(27, 37, 48, 0.6);
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; font-size: 11px;
border-left: 3px solid var(--tag);
background: rgba(255, 255, 255, 0.04);
border-radius: 1px 2px 2px 1px;
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: var(--mono); font-size: 12px; color: var(--ink); }
.today-list .pkg, .recent-list .pkg { font-size: 11px; color: var(--dim); }
.today-list .rem { font-family: var(--mono); font-size: 11px; color: var(--mint); }
.tchip b { font-family: "Cascadia Mono", "Consolas", ui-monospace, monospace; font-size: 13px; color: var(--scr-text); }
.recent-list li { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 11px; border-bottom: 1px solid rgba(27, 37, 48, 0.6); }
.recent-list .time { color: var(--dim); font-size: 10px; }
.recent-list .who b { font-family: var(--mono); font-size: 12px; margin-right: 4px; }
.recent-list .plus { margin-left: auto; color: var(--mint); font-family: var(--mono); font-style: normal; }
.recent-list .op { color: var(--dim); }
.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-foot { font-size: 10px; color: #47535f; text-align: right; letter-spacing: 0.12em; }
.scr-empty { margin: auto; color: var(--dim); font-size: 12px; text-align: center; }
.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; }
@media (max-width: 1100px) {
.scr-kpis { grid-template-columns: repeat(2, 1fr); }
/* ---------- 最近追加 ---------- */
.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; }
.heat-cell { min-height: 34px; }
.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) {
* { animation: none !important; transition: none !important; }
.heat-cell { transition: none; }
}
+134 -1
View File
@@ -1,4 +1,4 @@
/* 大屏:时钟 + 全屏快捷键 */
/* 大屏:时钟 + 全屏 + 主题 + 日历悬停/点按明细 */
(function () {
'use strict';
@@ -25,5 +25,138 @@
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(); });
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+57 -28
View File
@@ -1,41 +1,70 @@
{{define "layout"}}<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN"{{if .Theme}} data-bs-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} · 代打台</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="/static/favicon.svg?v={{.AssetVersion}}" type="image/svg+xml">
<script>
/* 首屏主题:服务端没有写 data-bs-theme 时(还没选过主题)跟随系统,避免闪白 */
(function () {
var d = document.documentElement;
if (d.hasAttribute('data-bs-theme')) return;
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
d.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
})();
</script>
<link rel="stylesheet" href="/static/vendor/bootstrap.min.css?v={{.AssetVersion}}">
<link rel="stylesheet" href="/static/app.css?v={{.AssetVersion}}">
</head>
<body>
{{if .User}}
<header class="topbar">
<a class="brand" href="/calendar"><span class="brand-mark">代</span><span class="brand-name">代打台</span></a>
<form class="search" action="/customers" method="get" role="search">
<input type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" autocomplete="off" data-search>
<kbd>/</kbd>
</form>
<nav class="nav">
<a href="/calendar" class="{{if eq .Nav "calendar"}}on{{end}}">日历</a>
<a href="/customers" class="{{if eq .Nav "customers"}}on{{end}}">客户</a>
<a href="/subscriptions" class="{{if eq .Nav "subscriptions"}}on{{end}}">订阅</a>
<a href="/packages" class="{{if eq .Nav "packages"}}on{{end}}">套餐</a>
<a href="/screen" target="_blank" rel="noopener" class="nav-screen">大屏 ↗</a>
</nav>
<div class="topbar-right">
<a class="pill pill-due" href="/subscriptions?state=due_today" title="今天到期的订阅">今日到期 <b>{{.DueToday}}</b></a>
<a class="pill pill-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a>
<div class="me">
<a href="/settings">{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}</a>
<form method="post" action="/logout"><button type="submit" class="link-btn">退出</button></form>
<nav class="navbar navbar-expand-xl app-navbar sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="/calendar"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</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>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link{{if eq .Nav "calendar"}} active{{end}}" href="/calendar">日历</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 "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>
<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>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="nav-side">
<a class="badge-due" href="/subscriptions?state=due_today" title="今天到期">今日到期 <b>{{.DueToday}}</b></a>
<a class="badge-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a>
<a class="badge-muted" href="/subscriptions?state=due7" title="7 天内到期">7 天内 <b>{{.Due7}}</b></a>
<button class="theme-toggle" 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="dropdown">
<button class="btn btn-soft btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/settings">设置</a></li>
<li><a class="dropdown-item" href="/logs">操作日志</a></li>
<li><hr class="dropdown-divider"></li>
<li><form method="post" action="/logout"><button class="dropdown-item text-danger" type="submit">退出登录</button></form></li>
</ul>
</div>
</div>
</div>
</div>
</header>
</nav>
{{end}}
{{if .Flash}}<div class="flash" data-flash>{{.Flash}}</div>{{end}}
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
<main{{if .User}} class="with-top"{{end}}>{{template "content" .}}</main>
{{if .User}}<footer class="foot">代打台 v{{.Version}} · <a href="/logs">操作日志</a> · <a href="/settings">设置</a> · <a href="/screen" target="_blank" rel="noopener">大屏</a></footer>{{end}}
<script src="/static/app.js" defer></script>
{{if .Flash}}<div class="flash" role="status" data-flash>{{.Flash}}</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>
{{if .User}}<footer class="app-foot">
<span>代打台 <span class="foot-version">v{{.Version}}</span></span>
<a href="/logs">操作日志</a>
<a href="/settings">设置</a>
<a href="/screen" target="_blank" rel="noopener">大屏</a>
</footer>{{end}}
<script src="/static/vendor/bootstrap.bundle.min.js?v={{.AssetVersion}}" defer></script>
<script src="/static/app.js?v={{.AssetVersion}}" defer></script>
</body>
</html>{{end}}
+38 -12
View File
@@ -3,9 +3,9 @@
{{define "pager"}}
{{if gt .Pages 1}}
<nav class="pager">
{{if .Prev}}<a class="btn btn-sm" href="{{.Prev}}">← 上一页</a>{{else}}<span class="btn btn-sm disabled">← 上一页</span>{{end}}
{{if .Prev}}<a class="btn btn-soft btn-sm" href="{{.Prev}}">← 上一页</a>{{else}}<span class="btn btn-soft btn-sm disabled">← 上一页</span>{{end}}
<span class="pager-info">{{.Page}} / {{.Pages}} 页 · 共 {{.Total}} 条</span>
{{if .Next}}<a class="btn btn-sm" href="{{.Next}}">下一页 →</a>{{else}}<span class="btn btn-sm disabled">下一页 →</span>{{end}}
{{if .Next}}<a class="btn btn-soft btn-sm" href="{{.Next}}">下一页 →</a>{{else}}<span class="btn btn-soft btn-sm disabled">下一页 →</span>{{end}}
</nav>
{{end}}
{{end}}
@@ -19,7 +19,7 @@
{{end}}
{{define "sub-card"}}
<article class="subcard" id="sub-{{.ID}}" style="--tag:{{.PackageColor}}">
<article class="subcard{{if eq .Status "closed"}} is-closed{{end}}" id="sub-{{.ID}}" style="--tag:{{.PackageColor}}">
<div class="subcard-head">
<span class="tag"></span>
<h3>{{.PackageName}}</h3>
@@ -38,25 +38,51 @@
<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-sm">今日打卡</button>
<button class="btn btn-primary btn-sm">今日打卡</button>
</form>
{{end}}
<a class="btn btn-primary btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
{{if .Abyss}}
{{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"}}
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
<button class="btn btn-sm">暂停</button>
<button class="btn btn-soft btn-sm">暂停</button>
</form>
{{else if eq .Status "paused"}}
<form method="post" action="/subscriptions/{{.ID}}/resume">
<button class="btn btn-sm">恢复</button>
<button class="btn btn-soft btn-sm">恢复</button>
</form>
{{end}}
<form method="post" action="/subscriptions/{{.ID}}/close" data-confirm="结束后该订阅不再出现在待打清单,确认结束?">
<button class="btn btn-sm btn-ghost">结束</button>
<button class="btn btn-ghost btn-sm">结束</button>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/reopen">
<button class="btn btn-sm">重新启用</button>
<button class="btn btn-soft btn-sm">重新启用</button>
</form>
<form method="post" action="/subscriptions/{{.ID}}/delete" data-confirm="将永久删除该订阅及其打卡/追加记录,无法恢复。确定删除?">
<input type="hidden" name="confirm" value="delete">
<button class="btn btn-ghost btn-sm text-danger">删除</button>
</form>
{{end}}
</div>
@@ -68,7 +94,7 @@
<header class="extend-head">
<div>
<h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加{{if .IsDay}}时长{{else}}次数{{end}}</h3>
<p class="muted">{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p>
<p>{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p>
</div>
<a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a>
</header>
@@ -103,7 +129,7 @@
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a>
<a class="btn btn-soft" href="{{.Back}}">取消</a>
</div>
</form>
{{else}}
@@ -127,7 +153,7 @@
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a>
<a class="btn btn-soft" href="{{.Back}}">取消</a>
</div>
</form>
{{end}}
+7 -4
View File
@@ -1,15 +1,18 @@
{{define "screenlayout"}}<!doctype html>
<html lang="zh-CN">
<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" type="image/svg+xml">
<link rel="stylesheet" href="/static/screen.css">
<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/screen.js" defer></script>
<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}}
+189 -64
View File
@@ -2,19 +2,30 @@
<div class="cal-page">
<header class="cal-head">
<div class="cal-title">
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}" aria-label="上个月">‹</a>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}&{{.D.FilterQuery}}" aria-label="上个月">‹</a>
<h1>{{.D.Title}}</h1>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}" aria-label="下个月">›</a>
<a class="btn btn-sm" href="/calendar">回到今天</a>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}&{{.D.FilterQuery}}" aria-label="下个月">›</a>
<a class="btn btn-soft btn-sm" href="/calendar?{{.D.FilterQuery}}">回到今天</a>
</div>
<div class="cal-stats">
<span><em>{{.D.MonthDue}}</em>本月到期</span>
<span><em>{{.D.MonthTasks}}</em>本月待打</span>
<span><em>{{.D.MonthDone}}</em>已完成</span>
{{if gt .D.MonthUrgent 0}}<span class="text-urgent"><em>{{.D.MonthUrgent}}</em>加急</span>{{end}}
<a class="btn btn-primary btn-sm" href="/customers/new">+ 新建客户</a>
</div>
</header>
{{if .D.HasTabs}}
<nav class="pkg-tabs">
{{range .D.Tabs}}
<a class="{{if .Active}}on{{end}}" style="--tab:{{.Color}}" href="{{.Href}}">
<i></i>{{.Name}}{{if gt .Count 0}}<em>{{.Count}}</em>{{end}}
</a>
{{end}}
</nav>
{{end}}
{{if .D.NoPackages}}
<div class="notice">还没有服务套餐,先去 <a href="/packages/new">新建套餐</a>,再给客户开通。</div>
{{else if .D.NoCustomers}}
@@ -29,21 +40,22 @@
{{range .D.Weeks}}
<div class="cal-week" role="row">
{{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}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}" role="gridcell">
<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}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&{{$.D.FilterQuery}}" role="gridcell"{{if .BlockColor}} style="--block:{{.BlockColor}};--block-pct:{{.BlockPercent}}%"{{end}}>
{{if .Strip}}<span class="asg-strip" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
<div class="cell-top">
<span class="dnum">{{.Day}}</span>
{{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}}
<span class="cell-badges">
{{if gt .UrgentCnt 0}}<span class="urgent-dot" title="{{.UrgentCnt}} 项加急"></span>{{end}}
{{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}}
</span>
</div>
{{if .BlockText}}<span class="cell-block" title="{{.BlockTitle}}">{{.BlockText}}</span>{{end}}
{{if .TaskCount}}
<div class="cell-load">{{.DoneCount}}/{{.TaskCount}} 单</div>
{{end}}
<div class="cell-tickets">
{{range .Tickets}}
<span class="ticket{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}" style="--tag:{{.Color}}"><b>{{.Tail}}</b><span class="tk-name">{{.Name}}</span><i>{{.Pkg}}</i></span>
{{end}}
{{if gt .More 0}}<span class="ticket more">+{{.More}}</span>{{end}}
<div class="cell-count">
<b>{{.DoneCount}}</b><span>/ {{.TaskCount}}</span>
</div>
{{end}}
</a>
{{end}}
</div>
@@ -51,19 +63,180 @@
</div>
<aside class="drawer">
<div class="drawer-clock">
<b data-clock>--:--:--</b>
<span>{{.D.TodayStr}}</span>
</div>
<header class="drawer-head">
<h2>{{.D.SelectedLabel}}</h2>
<div class="drawer-tags">
{{if .D.SelectedIsToday}}<span class="chip chip-active">今天</span>{{end}}
{{if .D.SelectedIsPast}}<span class="chip chip-closed">已过去</span>{{end}}
<span class="chip chip-plain">{{len .D.Tasks}} 待打</span>
<span class="chip chip-plain">{{.D.TaskPending}} 待完成</span>
</div>
</header>
<section class="drawer-block">
<h3>当日待完成 <em>{{.D.TaskPending}}</em><span class="muted small">/ {{len .D.Tasks}} 单</span></h3>
<form class="quick-form" method="post" action="/checkins/quick">
<input type="hidden" name="date" value="{{.D.Selected}}">
<input type="hidden" name="pkg" value="{{.D.PkgID}}">
<input type="hidden" name="tag" value="{{.D.Tag}}">
<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="手机尾号快速完成">
<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>
<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}}
<section class="picker">
<header class="picker-head">
<h4>尾号 {{.D.PickTail}} 的待完成项</h4>
<a class="btn btn-sm btn-ghost" href="{{.D.Back}}">收起</a>
</header>
{{if .D.PickRows}}
<form method="post" action="/checkins/batch">
<input type="hidden" name="date" value="{{.D.Selected}}">
<input type="hidden" name="back" value="{{.D.Back}}">
<ul class="pick-list">
{{range .D.PickRows}}
<li class="list-group-item px-0">
<label>
<input type="checkbox" name="sub_ids" value="{{.ID}}"{{if not .Done}}{{if not .Skipped}} checked{{end}}{{end}}>
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pick-pkg">{{.PackageName}}</span>
<span class="pick-remain">{{.RemainShort}}</span>
{{if .Done}}<span class="chip chip-active">已完成</span>{{end}}
{{if .Skipped}}<span class="chip chip-paused">已跳过</span>{{end}}
</label>
</li>
{{end}}
</ul>
<div class="actions">
<button class="btn btn-primary btn-sm" type="submit">确认完成选中项</button>
<a class="btn btn-soft btn-sm" href="{{.D.Back}}">取消</a>
</div>
</form>
{{else}}
<p class="muted">{{.D.PickMsg}}</p>
{{end}}
</section>
{{end}}
{{if .D.Tasks}}
<ul class="list-group list-group-flush task-list">
{{range .D.Tasks}}
{{if or .Done .Skipped}}
<li class="list-group-item task task-done px-0" style="--asg:{{.AssigneeHex}}">
<details class="done-fold">
<summary>
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="chip {{if .Done}}chip-active{{else}}chip-paused{{end}}">{{if .Done}}已完成{{else}}已跳过{{end}}</span>
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
<span class="fold-caret" aria-hidden="true"></span>
</summary>
<div class="done-detail">
<div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div>
{{if .Note}}<div class="task-note">{{.Note}}{{if .Operator}} · {{.Operator}}{{end}}</div>{{end}}
<div class="task-actions">
<form method="post" action="/checkins/{{.CheckinID}}/delete">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost" type="submit">撤销打卡</button>
</form>
<details class="more-actions">
<summary class="btn btn-sm btn-ghost" title="更多操作" aria-label="更多操作">⋯</summary>
<div class="more-menu">
<form method="post" action="/tasks/urgent">
<input type="hidden" name="sub_id" value="{{.ID}}">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="urgent" value="{{if .Urgent}}0{{else}}1{{end}}">
<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>
</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}}
</div>
</details>
</div>
</div>
</details>
</li>
{{else}}
<li class="list-group-item task{{if .Urgent}} urgent{{end}} px-0" style="--asg:{{.AssigneeHex}}">
<div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
</a>
<div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div>
{{if .Note}}<div class="task-note">{{.Note}}{{if .Operator}} · {{.Operator}}{{end}}</div>{{end}}
</div>
<div class="task-actions">
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="done">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-primary">完成</button>
</form>
<details class="more-actions">
<summary class="btn btn-sm btn-ghost" title="更多操作" aria-label="更多操作">⋯</summary>
<div class="more-menu">
{{if not .Season}}
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="skipped">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost" type="submit">跳过这次</button>
</form>
{{end}}
<form method="post" action="/tasks/urgent">
<input type="hidden" name="sub_id" value="{{.ID}}">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="urgent" value="{{if .Urgent}}0{{else}}1{{end}}">
<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>
</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}}
</div>
</details>
</div>
</li>
{{end}}
{{end}}
</ul>
{{else}}<p class="muted">这天没有待打任务。</p>{{end}}
</section>
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
<section class="drawer-block">
<h3>这天到期 <em>{{len .D.Due}}</em></h3>
{{if .D.Due}}
<ul class="due-list">
<ul class="list-group list-group-flush due-list">
{{range .D.Due}}
<li>
<a class="due-main" href="/customers/{{.CustomerID}}">
@@ -71,61 +244,13 @@
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</a>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
<a class="btn btn-soft btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加</a>
</li>
{{end}}
</ul>
{{else}}<p class="muted">没有到期订阅。</p>{{end}}
</section>
<section class="drawer-block">
<h3>当日待打 <em>{{len .D.Tasks}}</em></h3>
{{if .D.Tasks}}
<ul class="task-list">
{{range .D.Tasks}}
<li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}">
<div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
</a>
<div class="task-meta">
<span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div>
{{if .Note}}<div class="task-note">{{.Note}}{{if .Operator}} · {{.Operator}}{{end}}</div>{{end}}
</div>
<div class="task-actions">
{{if or .Done .Skipped}}
<span class="chip {{if .Done}}chip-active{{else}}chip-paused{{end}}">{{if .Done}}已完成{{else}}已跳过{{end}}</span>
<form method="post" action="/checkins/{{.CheckinID}}/delete">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost">撤销</button>
</form>
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="done">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-primary">完成</button>
</form>
<form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="status" value="skipped">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm btn-ghost">跳过</button>
</form>
{{end}}
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
</div>
</li>
{{end}}
</ul>
{{else}}<p class="muted">这天没有待打任务。</p>{{end}}
</section>
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
<section class="drawer-block">
<h3>近 7 天到期</h3>
{{if .D.OtherDue}}
@@ -3,28 +3,74 @@
<header class="cust-head">
<div class="cust-id">
<span class="cust-tail">{{.D.Customer.Tail}}</span>
<div>
<h1>{{.D.Customer.Name}}</h1>
<div class="cust-id-main">
<h1>{{.D.Customer.Name}}{{if .D.Customer.Archived}} <span class="chip chip-closed">已停用</span>{{end}}</h1>
<div class="cust-tags">
{{range .D.Customer.TagList}}<span class="tagchip">{{.}}</span>{{end}}
</div>
</div>
</div>
<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>
{{if .D.Customer.Archived}}
<form method="post" action="/customers/{{.D.Customer.ID}}/restore"><button class="btn btn-soft" type="submit">恢复客户</button></form>
{{else}}
<form method="post" action="/customers/{{.D.Customer.ID}}/archive" data-confirm="停用后该客户不再出现在日历和大屏,订阅会保留。确定停用?"><button class="btn btn-ghost" type="submit">停用客户</button></form>
{{end}}
<form method="post" action="/customers/{{.D.Customer.ID}}/delete" data-confirm="将永久删除该客户及其全部订阅、打卡与追加记录,无法恢复。确定删除?">
<input type="hidden" name="confirm" value="delete">
<button class="btn btn-ghost text-danger" type="submit">彻底删除</button>
</form>
<a class="btn btn-ghost" href="/customers">返回列表</a>
</div>
<dl class="cust-meta">
{{if .D.Customer.GameName}}<div><dt>游戏</dt><dd>{{.D.Customer.GameName}}</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.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>
</dl>
<div class="page-actions">
<a class="btn" href="/customers/{{.D.Customer.ID}}/edit">编辑资料</a>
<a class="btn btn-ghost" href="/customers">返回列表</a>
</div>
</header>
{{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}}
{{if .D.CustCal}}
<section class="section">
<div class="section-head">
<h2>当月排单</h2>
<div class="minical-nav">
<a class="btn btn-ghost btn-sm btn-square" href="{{.D.CustCal.Prev}}" aria-label="上个月">‹</a>
<b>{{.D.CustCal.Title}}</b>
<a class="btn btn-ghost btn-sm btn-square" href="{{.D.CustCal.Next}}" aria-label="下个月">›</a>
<span class="muted small">{{.D.CustCal.TaskDays}} 天有单 · {{.D.CustCal.TotalTasks}} 单</span>
</div>
</div>
<div class="minical">
<div class="minical-dow">{{range .D.CustCal.Weekdays}}<span>{{.}}</span>{{end}}</div>
{{range .D.CustCal.Weeks}}
<div class="minical-row">
{{range .}}
<a class="minical-cell{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if eq .Count 0}} no-task{{end}}{{if .AllDone}} all-done{{end}}" href="/calendar?d={{.Date}}" title="{{.Date}}">
<span class="mc-day">{{.Day}}</span>
{{if gt .Count 0}}<span class="mc-count">{{.Done}}/{{.Count}}</span>{{end}}
</a>
{{end}}
</div>
{{end}}
</div>
</section>
{{end}}
<section class="section">
<div class="section-head">
<h2>订阅</h2>
@@ -40,25 +86,28 @@
{{if .D.Packages}}
<form method="post" action="/customers/{{.D.Customer.ID}}/subscriptions" class="inline-form" data-sub-form>
<select name="package_id" required data-pkg-select>
<select class="form-select form-select-sm" name="package_id" required data-pkg-select aria-label="选择套餐">
<option value="">选择套餐…</option>
{{range .D.Packages}}
<option value="{{.ID}}" data-unit="{{.Unit}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
{{.Name}} · {{.UnitLabel}} {{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{if .Archived}}(已归档){{end}}
<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}}>
{{.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}}
</option>
{{end}}
</select>
<label class="inline-field">开始日
<input type="date" name="start_date" value="{{.TodayStr}}" required>
<label class="inline-field" data-start-wrap>开始日
<input class="form-control form-control-sm" type="date" name="start_date" value="{{.TodayStr}}" required>
</label>
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}}
天数 <input 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 class="inline-field" data-count-wrap hidden>
次数 <input type="number" name="total_count" min="1" max="10000" value="10">
次数 <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>
终止日(可留空)<input type="date" name="end_date">
终止日(可留空)<input class="form-control form-control-sm" type="date" name="end_date">
</label>
<button class="btn btn-primary" type="submit">开通订阅</button>
</form>
@@ -101,9 +150,9 @@
</div>
{{if .D.Adjustments}}
<div class="table-wrap">
<table class="table tight">
<table class="table tight table-adjust">
<thead>
<tr><th>时间</th><th>套餐</th><th>类型</th><th>计算起点</th><th>结束日变化</th><th>增量</th><th>备注</th><th>操作人</th></tr>
<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">备注</th><th scope="col">操作人</th></tr>
</thead>
<tbody>
{{range .D.Adjustments}}
+23 -12
View File
@@ -3,7 +3,7 @@
<header class="page-head">
<div>
<h1>{{if .D.IsNew}}新建客户{{else}}编辑客户{{end}}</h1>
<p class="muted">手机尾号 + 客户名 唯一;尾号是日常查询的主键。</p>
<p>手机尾号 + 客户名 唯一;尾号是日常查询的主键。</p>
</div>
</header>
@@ -11,37 +11,48 @@
action="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}/edit{{end}}">
<div class="grid-2">
<label class="field"><span>手机尾号 <i>*</i></span>
<input name="tail" value="{{.D.Customer.Tail}}" required maxlength="8" placeholder="例如 1234" autofocus>
<input type="text" name="tail" value="{{.D.Customer.Tail}}" required maxlength="8" placeholder="例如 1234" inputmode="numeric" autofocus>
</label>
<label class="field"><span>客户名 <i>*</i></span>
<input name="name" value="{{.D.Customer.Name}}" required maxlength="32" placeholder="例如 老张">
<input type="text" name="name" value="{{.D.Customer.Name}}" required maxlength="32" placeholder="例如 老张">
</label>
</div>
<div class="grid-2">
<label class="field"><span>游戏名</span>
<input name="game_name" value="{{.D.Customer.GameName}}" maxlength="32" placeholder="例如 王者荣耀">
<input type="text" name="game_name" value="{{.D.Customer.GameName}}" maxlength="32" placeholder="例如 王者荣耀">
</label>
<label class="field"><span>区服 / UID</span>
<input name="game_server" value="{{.D.Customer.GameServer}}" maxlength="32" placeholder="例如 微信区 123456">
<input type="text" name="game_server" value="{{.D.Customer.GameServer}}" maxlength="32" placeholder="例如 微信区 123456">
</label>
</div>
<div class="grid-2">
<label class="field"><span>联系方式</span>
<input 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 class="field"><span>来源渠道</span>
<input name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
<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>
<input type="text" name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
</label>
<label class="field"><span>标签</span>
<input type="text" name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)">
</label>
</div>
<label class="field"><span>标签</span>
<input name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)">
</label>
<label class="field"><span>备注</span>
<textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea>
</label>
<div class="actions">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}{{end}}">取消</a>
<a class="btn btn-soft" href="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}{{end}}">取消</a>
</div>
</form>
</div>
+15 -12
View File
@@ -3,39 +3,42 @@
<header class="page-head">
<div>
<h1>客户</h1>
<p class="muted">共 {{.D.Total}} 位 · 输入手机尾号可快速定位</p>
<p>共 {{.D.Total}} 位 · 输入手机尾号可快速定位</p>
</div>
<div class="page-actions">
{{if .D.ShowArchived}}
<a class="btn btn-ghost" href="/customers?q={{.D.Q}}">隐藏已停用</a>
{{else}}
<a class="btn btn-ghost" href="/customers?q={{.D.Q}}&archived=1">显示已停用{{if gt .D.ArchivedCount 0}} ({{.D.ArchivedCount}}){{end}}</a>
{{end}}
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
</div>
</header>
<form class="filterbar" method="get" action="/customers">
<input type="search" name="q" value="{{.D.Q}}" placeholder="手机尾号 / 姓名 / 游戏名 / 联系方式" autocomplete="off">
<button class="btn" type="submit">搜索</button>
<input type="search" name="q" value="{{.D.Q}}" placeholder="手机尾号 / 姓名 / 游戏名 / 联系方式" autocomplete="off" aria-label="搜索客户">
<button class="btn btn-soft" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/customers">清除</a>{{end}}
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<table class="table table-customers">
<thead>
<tr><th>尾号</th><th>客户</th><th>联系方式</th><th>订阅与剩余</th><th>标签</th><th></th></tr>
<tr><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>
{{range .D.List}}
<tr>
<tr{{if .Archived}} class="archived"{{end}}>
<td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td>
<td>
<a class="name" href="/customers/{{.ID}}">{{.Name}}</a>
<a class="name" href="/customers/{{.ID}}">{{.Name}}</a>{{if .Archived}} <span class="chip chip-closed">已停用</span>{{end}}
{{if .GameName}}<div class="muted small">{{.GameName}}{{if .GameServer}} · {{.GameServer}}{{end}}</div>{{end}}
</td>
<td class="muted small">{{if .Contact}}{{.Contact}}{{else}}—{{end}}</td>
<td>
<span class="summary due-{{.Level}}">{{.Summary}}</span>
</td>
<td><span class="summary due-{{.Level}}">{{.Summary}}</span></td>
<td>{{range .TagList}}<span class="tagchip">{{.}}</span>{{end}}</td>
<td class="right"><a class="btn btn-sm" href="/customers/{{.ID}}">详情</a></td>
<td class="right"><a class="btn btn-soft btn-sm" href="/customers/{{.ID}}">详情</a></td>
</tr>
{{end}}
</tbody>
@@ -47,7 +50,7 @@
<div class="empty-mark">票</div>
{{if .D.Q}}
<p>没有匹配「{{.D.Q}}」的客户。</p>
<a class="btn" href="/customers">看全部客户</a>
<a class="btn btn-soft" href="/customers">看全部客户</a>
{{else}}
<p>还没有客户。先建一位,再给他开套餐。</p>
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
+7 -6
View File
@@ -1,20 +1,21 @@
{{define "content"}}
<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>
<div class="auth-card">
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
<div class="auth-brand"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></div>
<h1>登录</h1>
<p class="muted">所有账户均为管理员,任何操作都会留痕。</p>
<form method="post" action="/login" class="form">
<p>所有账户均为管理员,任何操作都会留痕。</p>
<form method="post" action="/login" class="stack">
<input type="hidden" name="next" value="{{.Next}}">
<label class="field"><span>用户名</span>
<input type="text" name="username" required autofocus autocomplete="username">
<input class="form-control" id="username" type="text" name="username" required autofocus autocomplete="username">
</label>
<label class="field"><span>密码</span>
<input type="password" name="password" required autocomplete="current-password">
<input class="form-control" id="password" type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary btn-block">登录</button>
</form>
<p class="auth-foot muted">代打台 · 单文件部署 · 数据只在本机</p>
<p class="auth-foot">代打台 · 单文件部署 · 数据只在本机</p>
</div>
</div>
{{end}}
+5 -5
View File
@@ -3,20 +3,20 @@
<header class="page-head">
<div>
<h1>操作日志</h1>
<p class="muted">共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里</p>
<p>共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里</p>
</div>
</header>
<form class="filterbar" method="get" action="/logs">
<input type="search" name="q" value="{{.D.Q}}" placeholder="搜索摘要 / 动作 / 操作人" autocomplete="off">
<button class="btn" type="submit">搜索</button>
<input type="search" name="q" value="{{.D.Q}}" placeholder="搜索摘要 / 动作 / 操作人" autocomplete="off" aria-label="搜索日志">
<button class="btn btn-soft" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/logs">清除</a>{{end}}
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table tight">
<thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>摘要</th><th>IP</th></tr></thead>
<table class="table tight table-logs">
<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">IP</th></tr></thead>
<tbody>
{{range .D.List}}
<tr>
@@ -0,0 +1,95 @@
{{define "content"}}
<div class="page">
<header class="page-head">
<div>
<h1>{{.D.Package.Name}}</h1>
<p>
{{.D.Package.UnitLabel}} · {{.D.Package.AmountText}}{{if .D.Package.CycleLabel}} · {{.D.Package.CycleLabel}}{{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}}
</p>
</div>
<div class="page-actions">
<a class="btn btn-ghost" href="/packages">返回套餐</a>
<a class="btn btn-soft" href="/packages/{{.D.Package.ID}}/edit">编辑套餐</a>
</div>
</header>
{{if .D.Package.Note}}<div class="notice note">{{.D.Package.Note}}</div>{{end}}
<form class="filterbar" method="get" action="/packages/{{.D.Package.ID}}">
<input type="search" name="q" value="{{.D.Q}}" placeholder="按尾号 / 姓名筛选客户" aria-label="筛选客户">
<button class="btn btn-soft" type="submit">筛选</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/packages/{{.D.Package.ID}}">清除</a>{{end}}
<span class="muted small">共 {{len .D.Customers}} 位客户</span>
</form>
<form method="post" action="/packages/{{.D.Package.ID}}/subscribe" class="section card">
<h2>批量开通</h2>
<p class="muted small">勾选客户后一次开通;已经是该套餐的客户会自动跳过。{{if .D.Package.Season}}通行证从套餐区间开始,整段完成。{{else if .D.Package.Abyss}}按周期数开通,结束日自动计算。{{else}}开始日期默认今天,数量默认取套餐设置,可改。{{end}}</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">
<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="days" min="1" max="3650" value="{{.D.Package.DefaultAmount}}">
</label>
</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">
<table class="table tight">
<thead>
<tr>
<th style="width:44px"><input type="checkbox" data-select-all aria-label="全选可开通客户"></th>
<th scope="col">尾号</th><th scope="col">客户</th><th scope="col">当前订阅</th><th scope="col">状态</th>
</tr>
</thead>
<tbody>
{{range .D.Customers}}
{{$subscribed := index $.D.Subscribed .ID}}
<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><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 class="summary due-{{.Level}}">{{.Summary}}</td>
<td>{{if $subscribed}}<span class="chip chip-closed">已是此套餐</span>{{else}}<span class="chip chip-plain">可开通</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">开通选中的客户</button>
<span class="muted small">勾选框默认已按「未订阅」预选,可自行取消</span>
</div>
</form>
</div>
{{end}}
+37 -7
View File
@@ -3,28 +3,48 @@
<header class="page-head">
<div>
<h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1>
<p class="muted">按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数。</p>
<p>按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数;通行证整段完成;深渊按周期完成。</p>
</div>
</header>
<form method="post" class="form card" data-package-form
action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}">
<label class="field"><span>套餐名称 <i>*</i></span>
<input name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 赛季深渊 / 10 次副本">
<input type="text" name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 深渊通行证 / 10 次副本">
</label>
<fieldset class="field">
<span>计费方式 <i>*</i></span>
<div class="radios">
<label><input type="radio" name="unit" value="day"{{if eq .D.Package.Unit "day"}} checked{{end}}>
<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 eq .D.Package.Unit "count"}} checked{{end}}>
<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">
<label class="field"><span>默认量 <i>*</i></span>
<label class="field" data-amount-wrap><span><span data-amount-title>默认量</span> <i>*</i></span>
<div class="input-suffix">
<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>
@@ -35,22 +55,32 @@
</label>
</div>
<div class="grid-2">
<div class="grid-3">
<label class="field"><span>日历色签</span>
<input type="color" name="color" value="{{.D.Package.Color}}">
</label>
<label class="field"><span>标签(可选)</span>
<input type="text" name="tag" value="{{.D.Package.Tag}}" maxlength="20" list="pkg-tags" placeholder="例如:包月活动托">
<datalist id="pkg-tags">{{range .D.Tags}}<option value="{{.}}"></option>{{end}}</datalist>
<small>相同标签的套餐在日历里会合并成同一个页签</small>
</label>
<label class="field"><span>排序(小的靠前)</span>
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
</label>
</div>
<label class="check-line">
<input type="checkbox" name="calendar_separate" value="1"{{if .D.Package.CalendarSeparate}} checked{{end}}>
<span>在日历中独立显示<small>通行证 / 深渊建议勾选:会单独开一个标签页,按色块连续展示,和其他日常任务分开看</small></span>
</label>
<label class="field"><span>说明</span>
<textarea name="note" rows="2" maxlength="200" placeholder="套餐包含什么、怎么个打法">{{.D.Package.Note}}</textarea>
</label>
<div class="actions">
<button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="/packages">取消</a>
<a class="btn btn-soft" href="/packages">取消</a>
</div>
</form>
</div>
+8 -6
View File
@@ -3,16 +3,16 @@
<header class="page-head">
<div>
<h1>套餐</h1>
<p class="muted">新增套餐只需加一行配置,不会影响任何已有客户与订阅</p>
<p>新增套餐只需加一行配置,不会影响任何已有客户与订阅</p>
</div>
<div class="page-actions"><a class="btn btn-primary" href="/packages/new">+ 新增套餐</a></div>
</header>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<table class="table table-packages">
<thead>
<tr><th class="col-dot"></th><th>名称</th><th>类型</th><th>默认量</th><th>参考价</th><th>订阅中 / 总数</th><th>状态</th><th></th></tr>
<tr><th class="col-dot" scope="col"><span class="visually-hidden">颜色</span></th><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>
{{range .D.List}}
@@ -20,17 +20,19 @@
<td><span class="dot big" style="background:{{.Color}}"></span></td>
<td>
<b>{{.Name}}</b>
{{if .Tag}}<span class="tagchip">{{.Tag}}</span>{{end}}{{if .CalendarSeparate}}<span class="tagchip">独立日历</span>{{end}}
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
</td>
<td>{{.UnitLabel}}</td>
<td class="mono">{{.DefaultAmount}}{{if eq .Unit "day"}} 天{{else}} 次{{end}}</td>
<td class="mono">{{.AmountText}}</td>
<td class="mono">{{if .HasPrice}}¥{{money .PriceCents}}{{else}}—{{end}}</td>
<td class="mono">{{.ActiveCount}} / {{.SubCount}}</td>
<td>{{if .Archived}}<span class="chip chip-closed">已归档</span>{{else}}<span class="chip chip-active">启用中</span>{{end}}</td>
<td class="actions-cell">
<a class="btn btn-sm" href="/packages/{{.ID}}/edit">编辑</a>
<a class="btn btn-soft btn-sm" href="/packages/{{.ID}}">详情/批量</a>
<a class="btn btn-ghost btn-sm" href="/packages/{{.ID}}/edit">编辑</a>
<form method="post" action="/packages/{{.ID}}/archive"{{if not .Archived}} data-confirm="归档后不能再新建该套餐的订阅,已有订阅不受影响。继续?"{{end}}>
<button class="btn btn-sm btn-ghost">{{if .Archived}}恢复{{else}}归档{{end}}</button>
<button class="btn btn-ghost btn-sm">{{if .Archived}}恢复{{else}}归档{{end}}</button>
</form>
</td>
</tr>
+125
View File
@@ -0,0 +1,125 @@
{{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}}
+68 -24
View File
@@ -1,7 +1,26 @@
{{define "content"}}
<div class="scr">
<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">代</span>代打台<em>控制台</em></div>
<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>
@@ -24,24 +43,29 @@
<section class="scr-main">
<div class="panel panel-cal">
<div class="panel-head">
<h2>到期热力</h2>
<span>{{.D.MonthTitle}} · 本月 {{.D.Stats.MonthDue}} 条到期</span>
<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}}">
<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 .Due}}<b>{{.Due}}</b>{{end}}
{{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>到期数</span><i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
<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>
@@ -69,12 +93,12 @@
<section class="scr-bottom">
<div class="panel">
<div class="panel-head">
<h2>今日待打</h2>
<span>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成</span>
<h2>今日到期</h2>
<span>{{len .D.DueToday}} 条</span>
</div>
{{if .D.TodayList}}
{{if .D.DueToday}}
<ul class="today-list">
{{range .D.TodayList}}
{{range .D.DueToday}}
<li class="lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
@@ -83,31 +107,51 @@
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有待打任务</p>
<p class="scr-empty">今天没有到期</p>
{{end}}
</div>
<div class="panel">
<div class="panel-head"><h2>最近追加</h2><span>服务时长变更流水</span></div>
{{if .D.Recent}}
<ul class="recent-list">
{{range .D.Recent}}
<li>
<span class="time mono">{{.CreatedAt}}</span>
<span class="who"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<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>
{{if gt .DaysAdded 0}}<em class="plus">+{{.DaysAdded}} 天</em>{{end}}
{{if gt .CountAdded 0}}<em class="plus">+{{.CountAdded}} 次</em>{{end}}
<span class="op">{{.OperatorName}}</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>
<p class="scr-empty">今天没有待打任务</p>
{{end}}
</div>
</section>
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 进入全屏</footer>
<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}}
+22 -11
View File
@@ -3,7 +3,7 @@
<header class="page-head">
<div>
<h1>设置</h1>
<p class="muted">预警天数、备份、账户与数据导出</p>
<p>预警天数、备份、账户与数据导出</p>
</div>
</header>
@@ -25,16 +25,23 @@
<section class="section card">
<h2>管理员</h2>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。</p>
<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">
<thead><tr><th>用户名</th><th>显示名称</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></th></tr></thead>
<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>
@@ -42,8 +49,11 @@
{{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-sm btn-ghost">{{if .IsActive}}停用{{else}}启用{{end}}</button>
<button class="btn btn-ghost btn-sm">{{if .IsActive}}停用{{else}}启用{{end}}</button>
</form>
{{end}}
</td>
@@ -53,8 +63,8 @@
</table>
</div>
<form method="post" action="/settings/users" class="inline-form">
<label class="inline-field">用户名 <input name="username" required minlength="3" maxlength="32" autocomplete="off"></label>
<label class="inline-field">显示名称 <input name="display_name" maxlength="32"></label>
<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>
@@ -79,15 +89,16 @@
<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">/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}</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>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div>
</dl>
<div class="actions wrap">
<form method="post" action="/settings/backup"><button class="btn" type="submit">立即备份</button></form>
<a class="btn" href="/export/customers.csv">导出客户 CSV</a>
<a class="btn" href="/export/subscriptions.csv">导出订阅 CSV</a>
<a class="btn" href="/export/logs.csv">导出日志 CSV</a>
<form method="post" action="/settings/backup"><button class="btn btn-soft" type="submit">立即备份</button></form>
<a class="btn btn-soft" href="/export/customers.csv">导出客户 CSV</a>
<a class="btn btn-soft" href="/export/subscriptions.csv">导出订阅 CSV</a>
<a class="btn btn-soft" href="/export/logs.csv">导出日志 CSV</a>
</div>
{{if .D.Backups}}
<details class="backups">
+9 -8
View File
@@ -1,25 +1,26 @@
{{define "content"}}
<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>
<div class="auth-card">
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
<div class="auth-brand"><span class="brand-mark" aria-hidden="true">代</span><span>代打台</span></div>
<h1>创建第一个管理员</h1>
<p class="muted">数据库里还没有账户。设置好账号后即可开始录入套餐与客户。</p>
<form method="post" action="/setup" class="form">
<p>数据库里还没有账户。设置好账号后即可开始录入套餐与客户。</p>
<form method="post" action="/setup" class="stack">
<label class="field"><span>用户名</span>
<input 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 class="field"><span>显示名称</span>
<input 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 class="field"><span>密码(至少 6 位)</span>
<input 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 class="field"><span>再输一次密码</span>
<input type="password" name="password2" required minlength="6" autocomplete="new-password">
<input class="form-control" id="password2" type="password" name="password2" required minlength="6" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">创建并登录</button>
</form>
<p class="auth-foot muted">忘记密码只能手动改数据库,请妥善保管。</p>
<p class="auth-foot">忘记密码只能手动改数据库,请妥善保管。</p>
</div>
</div>
{{end}}
+13 -13
View File
@@ -3,11 +3,11 @@
<header class="page-head">
<div>
<h1>订阅</h1>
<p class="muted">共 {{.D.Total}} 条 · 剩余时长各自独立计算</p>
<p>共 {{.D.Total}} 条 · 剩余时长各自独立计算</p>
</div>
</header>
<div class="tabs">
<nav class="tabs" aria-label="订阅状态">
<a href="/subscriptions?state=active" class="{{if eq .D.State "active"}}on{{end}}">进行中</a>
<a href="/subscriptions?state=due7" class="{{if eq .D.State "due7"}}on{{end}}">7 天内到期</a>
<a href="/subscriptions?state=due_today" class="{{if eq .D.State "due_today"}}on{{end}}">今天到期</a>
@@ -15,25 +15,25 @@
<a href="/subscriptions?state=paused" class="{{if eq .D.State "paused"}}on{{end}}">暂停中</a>
<a href="/subscriptions?state=closed" class="{{if eq .D.State "closed"}}on{{end}}">已结束</a>
<a href="/subscriptions?state=all" class="{{if eq .D.State "all"}}on{{end}}">全部</a>
</div>
</nav>
<form class="filterbar" method="get" action="/subscriptions">
<input type="hidden" name="state" value="{{.D.State}}">
<input type="search" name="q" value="{{.D.Q}}" placeholder="尾号 / 姓名 / 套餐" autocomplete="off">
<select name="package_id">
<input type="search" name="q" value="{{.D.Q}}" placeholder="尾号 / 姓名 / 套餐" autocomplete="off" aria-label="搜索订阅">
<select name="package_id" aria-label="按套餐筛选">
<option value="0">全部套餐</option>
{{range .D.Packages}}
<option value="{{.ID}}"{{if eq $.D.PackageID .ID}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<button class="btn" type="submit">筛选</button>
<button class="btn btn-soft" type="submit">筛选</button>
</form>
{{if .D.List}}
<div class="table-wrap">
<table class="table">
<table class="table table-subs">
<thead>
<tr><th>客户</th><th>套餐</th><th>状态</th><th>剩余</th><th>时间</th><th>操作</th></tr>
<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></tr>
</thead>
<tbody>
{{range .D.List}}
@@ -49,14 +49,14 @@
<td class="mono small">{{.PeriodText}}</td>
<td class="actions-cell">
{{if ne .Status "closed"}}
<a class="btn btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>
{{if not (or .Season .Abyss)}}<a class="btn btn-soft btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>{{end}}
{{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-sm btn-ghost">暂停</button></form>
<form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-ghost btn-sm">暂停</button></form>
{{else if eq .Status "paused"}}
<form method="post" action="/subscriptions/{{.ID}}/resume"><button class="btn btn-sm">恢复</button></form>
<form method="post" action="/subscriptions/{{.ID}}/resume"><button class="btn btn-soft btn-sm">恢复</button></form>
{{end}}
{{else}}
<form method="post" action="/subscriptions/{{.ID}}/reopen"><button class="btn btn-sm">重新启用</button></form>
<form method="post" action="/subscriptions/{{.ID}}/reopen"><button class="btn btn-soft btn-sm">重新启用</button></form>
{{end}}
</td>
</tr>
@@ -69,7 +69,7 @@
<div class="empty">
<div class="empty-mark">票</div>
<p>这个筛选下没有订阅。</p>
<a class="btn" href="/subscriptions?state=all">看全部</a>
<a class="btn btn-soft" href="/subscriptions?state=all">看全部</a>
</div>
{{end}}
</div>
@@ -0,0 +1,39 @@
{{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}}
+75 -12
View File
@@ -29,8 +29,30 @@ type SubView struct {
Today string
// CanCheckinToday 表示今天可以直接打卡(订阅在有效状态与区间内)
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) {
switch state {
case domain.StateActive:
@@ -59,7 +81,27 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
StateClass: class,
Today: domain.Date(today),
}
if s.Unit == domain.UnitDay {
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.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
@@ -70,8 +112,8 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
}
} else {
v.UnitLabel = "按次"
v.RemainText = fmt.Sprintf("剩 %d / %d 次", e.RemainingCount, s.TotalCount)
v.RemainShort = fmt.Sprintf("%d 次", e.RemainingCount)
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
@@ -79,6 +121,7 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
v.PeriodText += " · 无有效期"
}
if s.TotalCount > 0 {
v.Progress = clampPct(s.UsedCount * 100 / s.TotalCount)
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
}
}
@@ -109,9 +152,29 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
v.DueText = "次数已用尽"
}
}
// 今天能否打卡:进行中且(按日或有剩余次数)
v.CanCheckinToday = e.State == domain.StateActive &&
// 整段型:完成后统一显示「已完成」,不再提示到期
if s.Season && s.SeasonDone != "" {
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
}
@@ -329,13 +392,13 @@ func buildTimeline(subs []store.Subscription, pauses map[int64][]store.Pause, to
en = st
}
b := Bar{
SubID: s.ID,
Name: s.PackageName,
Color: s.PackageColor,
Left: pos(st),
Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100,
Label: domain.Date(st) + " → " + domain.Date(en),
State: s.Status,
SubID: s.ID,
Name: s.PackageName,
Color: s.PackageColor,
Left: pos(st),
Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100,
Label: domain.Date(st) + " → " + domain.Date(en),
State: s.Status,
HasDue: s.EndDate != "",
}
b.DueLeft = pos(en)
+2 -1
View File
@@ -26,7 +26,7 @@ import (
"daili/internal/web"
)
const version = "1.1.0"
const version = "1.11.5"
func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
@@ -131,6 +131,7 @@ func main() {
BackupDir: cfg.BackupDir,
BackupEnabled: cfg.BackupEnabled,
ScreenPublic: cfg.ScreenPublic,
PortalPublic: cfg.PortalPublic,
ConfigPath: cfg.Path,
Version: version,
})
+41 -19
View File
@@ -2,10 +2,11 @@
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\acceptance.ps1 -Base http://127.0.0.1:18090 -Db <测试库路径>
param(
[string]$Base = 'http://127.0.0.1:18090',
[string]$Db = 'F:\hupeE的代肝平台\testrun\daili.db',
[string]$Python = 'E:\Miniconda3\python.exe',
[string]$Tools = 'F:\hupeE的代肝平台\scripts\dbtool.py',
[string]$LogFile = 'F:\hupeE的代肝平台\testrun\acceptance-result.txt'
[string]$Root = '',
[string]$Db = '',
[string]$Python = '',
[string]$Tools = '',
[string]$LogFile = ''
)
try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
@@ -13,6 +14,26 @@ try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONIOENCODING = 'utf-8'
# ---- 路径与外部程序:全部基于脚本所在目录,换机器/换目录都不用改 ----
function Find-Python {
foreach ($c in @($env:PYTHON, 'python', 'py')) {
if (-not $c) { continue }
$cmd = Get-Command $c -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
}
foreach ($p in @('E:\Miniconda3\python.exe', 'C:\Python312\python.exe', 'C:\Python311\python.exe')) {
if (Test-Path $p) { return $p }
}
throw '找不到 Python 解释器,请用 -Python 指定路径'
}
if (-not $Root) { $Root = Split-Path -Parent $PSScriptRoot }
if (-not $Db) { $Db = Join-Path $Root 'testrun\daili.db' }
if (-not $Tools) { $Tools = Join-Path $Root 'scripts\dbtool.py' }
if (-not $LogFile) { $LogFile = Join-Path $Root 'testrun\acceptance-result.txt' }
if (-not $Python) { $Python = Find-Python }
$BackupDir = Join-Path $Root 'testrun\backup'
$LogDir = Join-Path $Root 'testrun\logs'
$VerifyScript = Join-Path $Root 'scripts\verify-backup.py'
$script:results = @()
$script:fail = 0
$T = (Get-Date).Date
@@ -208,7 +229,7 @@ $r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '2';
Check 'U-02' '开通按次订阅(10 次 / 有效期 T+10)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$p2ID = (SubIDs $r.Content)[1]
Check 'U-02b' '按次剩余 10/10 次' ((Has $r.Content '剩 10 / 10 次')) ""
Check 'U-02b' '按次已完成 0/10 次' ((Has $r.Content '已完成 0 / 10 次')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
Check 'U-03' '重复订阅被拦截' ((Has $r.Content '已订阅此套餐')) "flash=$(Flash $r.Content)"
@@ -218,7 +239,7 @@ $before = (Send $admin 'GET' "/customers/$custID").Content
$r = Send $admin 'POST' '/packages' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3' }
Check 'P-05' '新增套餐 D 成功' ((Has $r.Content '套餐已创建')) ""
$after = (Send $admin 'GET' "/customers/$custID").Content
Check 'P-05b' '新增套餐后既有订阅剩余量不变' ((Has $after '剩 30 天') -and (Has $after '剩 10 / 10 次')) ""
Check 'P-05b' '新增套餐后既有订阅进度不变' ((Has $after '剩 30 天') -and (Has $after '已完成 0 / 10 次')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '3'; start_date = $today; total_count = '5' }
Check 'U-04' '开通第三个套餐(无有效期按次)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
@@ -292,7 +313,7 @@ Check 'E-07' '已结束订阅不可追加' ((Has $r.Content '请先「重新启
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; note = '加购 5 次'; back = "/customers/$custID" }
$d4 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-08' '按次追加次数' ((Has $r.Content '已追加 5 次') -and (Has $d4 '剩 15 / 15 次')) "flash=$(Flash $r.Content)"
Check 'E-08' '按次追加次数' ((Has $r.Content '已追加 5 次') -and (Has $d4 '已完成 0 / 15 次')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; new_end_opt = (D 30); note = '加次并延期'; back = "/customers/$custID" }
$d5 = (Send $admin 'GET' "/customers/$custID").Content
@@ -344,22 +365,22 @@ Check 'K-02' '按日打卡不扣天数' ((Has $d '剩 30 天')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-03' '按次打卡扣 1 次' ((Has $d '剩 19 / 20 次')) ""
Check 'K-03' '按次打卡扣 1 次' ((Has $d '已完成 1 / 20 次')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-04' '同日重复打卡不重复扣次' ((Has $d '剩 19 / 20 次')) ""
Check 'K-04' '同日重复打卡不重复扣次' ((Has $d '已完成 1 / 20 次')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'skipped'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-05' '改为跳过返还 1 次' ((Has $d '剩 20 / 20 次')) ""
Check 'K-05' '改为跳过返还 1 次' ((Has $d '已完成 0 / 20 次')) ""
Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" } | Out-Null
$d = (Send $admin 'GET' "/customers/$custID").Content
$ciID = (Dbtool @('scalar', $Db, "SELECT id FROM checkins WHERE sub_id=$p2ID AND service_date='$today'")).Trim()
$r = Send $admin 'POST' "/checkins/$ciID/delete" @{ back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-06' '撤销打卡返还次数' ((Has $r.Content '已撤销') -and (Has $d '剩 20 / 20 次')) "flash=$(Flash $r.Content)"
Check 'K-06' '撤销打卡返还次数' ((Has $r.Content '已撤销') -and (Has $d '已完成 0 / 20 次')) "flash=$(Flash $r.Content)"
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=total_count WHERE id=$p2ID") | Out-Null
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
@@ -374,7 +395,7 @@ Check 'K-11' '无有效期订阅卡片有「今日打卡」按钮' ((Has $r.Cont
$r = Send $admin 'POST' "/subscriptions/$p3ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-09' '无有效期按次订阅可打卡扣次' ((Has $d '剩 4 / 5 次')) "flash=$(Flash $r.Content)"
Check 'K-09' '无有效期按次订阅可打卡扣次' ((Has $d '已完成 1 / 5 次')) "flash=$(Flash $r.Content)"
Check 'K-10' '打卡历史含日期/备注/操作人' ((Has $d '今日已打') -or (Has $d '完成')) ""
Write-Host "`n=== 11. 日历(L) ===" -ForegroundColor Cyan
@@ -389,7 +410,7 @@ $r = Send $admin 'GET' '/calendar?y=2020&m=1'
Check 'L-03' '「回到今天」入口存在' ((Has $r.Content '回到今天')) ""
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
Check 'L-04' '格子显示色签与负荷' ((Has $cal '单测') -and (Has $cal '包月') -and (Has $cal 'heat-')) ""
Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待打')) ""
Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待完成')) ""
$r = Send $admin 'GET' "/calendar?d=$(D -1)"
Check 'L-06' '过去日期标记已过去' ((Has $r.Content '已过去')) ""
Check 'L-07' '近 7 天到期区块' ((Has $cal '近 7 天到期')) ""
@@ -412,11 +433,12 @@ $kpiCust = [regex]::Match($screen, '(?s)客户</span>\s*<b>(\d+)</b>').Groups[1]
$kpiDue = [regex]::Match($screen, '(?s)今日到期</span>\s*<b>(\d+)</b>').Groups[1].Value
$kpi7 = [regex]::Match($screen, '(?s)7 天内到期</span>\s*<b>(\d+)</b>').Groups[1].Value
Check 'D-02' '四张 KPI 与后台一致' (($kpiCust -eq $custN) -and ($kpiDue -eq $dueToday) -and ($kpi7 -eq $due7)) "cust=$kpiCust/$custN due=$kpiDue/$dueToday 7=$kpi7/$due7"
Check 'D-03' '热力月历渲染' ((Has $screen '到期热力') -and (Has $screen 'heat-cell')) ""
$screenDue = (Send $admin 'GET' '/screen?heat=due').Content
Check 'D-03' '热力月历渲染(默认待完成,可切到期)' ((Has $screen '每日待完成') -and (Has $screen 'heat-cell') -and (Has $screenDue '到期热力')) ""
$topN = [regex]::Matches($screen, 'class="top-item').Count
Check 'D-04' 'TOP 榜不超过 10 条' ((Has $screen '即将到期 TOP 10') -and ($topN -ge 1) -and ($topN -le 10)) "count=$topN"
$todayTasks = [regex]::Match($cal, '(\d+) 待打').Groups[1].Value
$screenTasks = [regex]::Match($screen, '<span>(\d+) / (\d+) 已完成</span>').Groups[2].Value
$todayTasks = [regex]::Match($cal, '(\d+) 待完成').Groups[1].Value
$screenTasks = [regex]::Match($screen, '(\d+) / (\d+) 已完成').Groups[2].Value
Check 'D-05' '今日待打数与日历一致' ($todayTasks -eq $screenTasks -and $todayTasks -ne '') "calendar=$todayTasks screen=$screenTasks"
Check 'D-06' '最近追加流水渲染' ((Has $screen '最近追加')) ""
Check 'D-07' '自动刷新秒数取自设置' ((Has $screen '<meta http-equiv="refresh" content="30">')) ""
@@ -444,7 +466,7 @@ Check 'R-06' '日志 CSV 含操作人/动作' ((Has $csv3 '操作人') -and (Has
$r = Send $admin 'POST' '/settings/backup'
Check 'R-07' '手动备份生成文件' ((Has $r.Content '已备份')) "flash=$(Flash $r.Content)"
$verify = (& $Python 'F:\hupeE的代肝平台\scripts\verify-backup.py' 'F:\hupeE的代肝平台\testrun\backup') -join ' '
$verify = (& $Python $VerifyScript $BackupDir) -join ' '
Check 'R-07b' '备份文件可被标准 SQLite 打开且有数据' (($verify -match 'OK') -and ($verify -match 'customers\s+(\d+)')) $verify
Send $admin 'POST' '/settings' @{ warn_days = '3'; screen_refresh = '30' } | Out-Null
@@ -453,7 +475,7 @@ for ($i = 0; $i -lt 3; $i++) {
Start-Sleep -Milliseconds 1200
Send $admin 'POST' '/settings/backup' | Out-Null
}
$bk = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\backup' -Filter 'daili-*.db')
$bk = @(Get-ChildItem $BackupDir -Filter 'daili-*.db')
$pruneOk = ($keepShown -ge 1) -and ($bk.Count -le $keepShown) -and (($keepShown -gt 3) -or ($bk.Count -eq $keepShown))
Check 'R-08' "备份保留份数生效(配置 $keepShown 份)" $pruneOk "count=$($bk.Count) keep=$keepShown"
@@ -537,7 +559,7 @@ Check 'CF-02' '设置页展示备份目录与保留份数' ((Has $settingsHtml '
$anon2 = New-Sess
$r = Send $anon2 'GET' '/screen'
Check 'CF-03' '大屏免登录(screen_public = true)' ($r.Status -eq 200 -and $r.Path -eq '/screen') "status=$($r.Status) path=$($r.Path)"
$logs = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\logs' -Filter '*.log' -ErrorAction SilentlyContinue)
$logs = @(Get-ChildItem $LogDir -Filter '*.log' -ErrorAction SilentlyContinue)
$logText = ''
if ($logs.Count -ge 1) { $logText = Get-Content $logs[0].FullName -Raw -Encoding UTF8 }
Check 'CF-04' '运行日志落盘' ($logs.Count -ge 1 -and $logText.Length -gt 20) "files=$($logs.Count) bytes=$($logText.Length)"
+10 -4
View File
@@ -1,11 +1,17 @@
# 配置文件专项测试:验证 daili.ini 的生成、解析、覆盖与各项生效
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\config-test.ps1
param(
[string]$Exe = 'F:\hupeE的代肝平台\daili.exe',
[string]$Work = 'F:\hupeE的代肝平台\cfgtest-run',
[string]$Exe = '',
[string]$Work = '',
[string]$LanIp = ''
)
$Root = Split-Path -Parent $PSScriptRoot
if (-not $Exe) { $Exe = Join-Path $Root 'daili.exe' }
if (-not $Work) { $Work = Join-Path $Root 'config-run' }
$Evidence = Join-Path $Root 'test-evidence'
if (-not (Test-Path $Exe)) { throw "找不到被测程序:$Exe(先构建或已用 -Exe 指定)" }
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -AssemblyName System.Net.Http
@@ -226,8 +232,8 @@ Stop-App $h
$pass = @($script:results | Where-Object { $_ -like 'PASS|*' }).Count
$fail = @($script:results | Where-Object { $_ -like 'FAIL|*' }).Count
Write-Host "`n=== 汇总:通过 $pass 项,失败 $fail 项 ===" -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
New-Item -ItemType Directory -Force -Path 'F:\hupeE的代肝平台\test-evidence' | Out-Null
$script:results | Set-Content 'F:\hupeE的代肝平台\test-evidence\config-result.txt' -Encoding UTF8
New-Item -ItemType Directory -Force -Path $Evidence | Out-Null
$script:results | Set-Content (Join-Path $Evidence 'config-result.txt') -Encoding UTF8
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
if ($fail -gt 0) { exit 1 }
exit 0
+10 -4
View File
@@ -1,12 +1,18 @@
# 数据可移植性测试:单文件、只拷 daili.db 无损迁移、删库即重置
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\portability-test.ps1
param(
[string]$Exe = 'F:\hupeE的代肝平台\daili.exe',
[string]$Work = 'F:\hupeE的代肝平台\portable-run',
[string]$Exe = '',
[string]$Work = '',
[int]$PortA = 17781,
[int]$PortB = 17782
)
$Root = Split-Path -Parent $PSScriptRoot
if (-not $Exe) { $Exe = Join-Path $Root 'daili.exe' }
if (-not $Work) { $Work = Join-Path $Root 'portability-run' }
$Evidence = Join-Path $Root 'test-evidence'
if (-not (Test-Path $Exe)) { throw "找不到被测程序:$Exe(先构建或已用 -Exe 指定)" }
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -AssemblyName System.Net.Http
@@ -153,8 +159,8 @@ Stop-App $hA2
$pass = @($script:results | Where-Object { $_ -like 'PASS|*' }).Count
$fail = @($script:results | Where-Object { $_ -like 'FAIL|*' }).Count
Write-Host "`n=== 汇总:通过 $pass 项,失败 $fail 项 ===" -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
New-Item -ItemType Directory -Force -Path 'F:\hupeE的代肝平台\test-evidence' | Out-Null
$script:results | Set-Content 'F:\hupeE的代肝平台\test-evidence\portability-result.txt' -Encoding UTF8
New-Item -ItemType Directory -Force -Path $Evidence | Out-Null
$script:results | Set-Content (Join-Path $Evidence 'portability-result.txt') -Encoding UTF8
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
if ($fail -gt 0) { exit 1 }
exit 0