Compare commits

..
21 Commits
Author SHA1 Message Date
chun_qiu 7f0a27dbef ci(v1.11.5-L): lite 分支纳入构建流水线
build / build-and-test (push) Canceled after 0s
- .gitea/workflows/build.yml 的 on.push.branches 增加 lite
- 推送到 lite 会触发 vet → test → 交叉编译 windows/linux → 上传产物
- 其余触发条件不变(main/master、v* 标签、PR、手动执行)
2026-10-21 03:43:08 +08:00
chun_qiu cde1c84c9a feat(v1.11.5-L): lite 精简版——移除大屏/客户自查/多用户/小键盘,套餐仅保留按日
需求与确认(用户逐项确认):
- 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式
- 打手归属一并移除(只剩一个人,颜色与指派没有区分意义)
- 盲水印做成完全不可见:只存在于 DOM / 源码,肉眼与截图都看不到
- 拼写采用更正后的 Powered by Baka春秋
- 版本号与品牌全站统一带 -L
- screen_public / portal_public 保留解析但不起作用,保证旧 ini 能直接启动
- README 同步更新

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

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

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

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

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

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

README:改为 lite 版说明,删除大屏/客户自查/通行证/深渊/按次/小键盘/多用户章节,
新增「盲水印」与「与 main 分支的差异」两节(含老数据折算规则与列保留说明)。
2026-10-21 03:33:27 +08:00
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
49 changed files with 2144 additions and 4205 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
name: build name: build
# 触发:主干推送、版本标签、PR、手动执行 # 触发:主干与 lite 分支推送、版本标签、PR、手动执行
on: on:
push: push:
branches: [main, master] branches: [main, master, lite]
tags: ['v*'] tags: ['v*']
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
+90 -83
View File
@@ -1,8 +1,12 @@
# 代打台 · 代打管理平台 # 代打台-L · 代打管理平台(lite 版)
一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡 → 大屏**。 一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡**。
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。界面支持亮色 / 暗色切换,默认跟随系统。 整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。界面支持亮色 / 暗色切换,默认跟随系统。
> **这是 lite 分支**:只保留「按日(包时)」一种套餐,去掉了大屏、客户自助查询、多管理员、触摸小键盘。
> 版本号统一带 `-L` 后缀(当前 `1.11.5-L`),界面品牌为「代打台-L」,页脚署名 `Powered by Baka春秋`。
> 与 `main` 分支的差异见文末「[10. 与 main 分支的差异](#10-与-main-分支的差异)」。
--- ---
## 1. 快速开始 ## 1. 快速开始
@@ -12,20 +16,18 @@
- 首次运行会自动生成配置文件 `daili.ini`; - 首次运行会自动生成配置文件 `daili.ini`;
- 弹出一个黑色窗口,打印访问地址(默认 **17777** 端口); - 弹出一个黑色窗口,打印访问地址(默认 **17777** 端口);
- 默认会自动打开浏览器; - 默认会自动打开浏览器;
3. 浏览器打开 `http://127.0.0.1:17777` → 第一次会让你**创建管理员账号**; 3. 浏览器打开 `http://127.0.0.1:17777` → 第一次会让你**创建管理员账号**(这是唯一的账号,之后不能再添加);
4. 之后按「套餐 → 客户 → 订阅」的顺序录入即可。 4. 之后按「套餐 → 客户 → 订阅」的顺序录入即可。
``` ```
┌───────────────────────────────────────────────┐ ┌───────────────────────────────────────────────┐
│ 代 打 台 · 代打管理平台 │ │ 代 打 台 - L · 代打管理平台 │
└───────────────────────────────────────────────┘ └───────────────────────────────────────────────┘
版本 1.1.0 配置文件 daili.ini 版本 1.11.5-L 配置文件 daili.ini
数据库 D:\代打台\daili.db 数据库 D:\代打台\daili.db
访问地址: 访问地址:
本机 http://127.0.0.1:17777 本机 http://127.0.0.1:17777
局域网 http://192.168.1.23:17777 局域网 http://192.168.1.23:17777
大屏 http://127.0.0.1:17777/screen(免登录)
注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen
按 Ctrl+C 停止服务 按 Ctrl+C 停止服务
``` ```
@@ -48,17 +50,19 @@
| `backup_keep` | `14` | 备份保留份数(1–365),超出自动清理 | | `backup_keep` | `14` | 备份保留份数(1–365),超出自动清理 |
| `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** | | `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** |
| `open_browser` | `true` | 启动后自动打开浏览器 | | `open_browser` | `true` | 启动后自动打开浏览器 |
| `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 |
| `log_enabled` | `true` | 运行日志写文件(控制台始终输出) | | `log_enabled` | `true` | 运行日志写文件(控制台始终输出) |
| `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` | | `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` |
> `screen_public`(大屏)与 `portal_public`(客户自助)在 lite 版**已废弃**:仍然接受解析,但不起任何作用。
> 保留它们只是为了让你从 `main` 升级时,旧 `daili.ini` 能直接启动,不会被「未知配置项」拦住。
**行为约定** **行为约定**
- 文件不存在 → 自动生成默认配置,照常启动; - 文件不存在 → 自动生成默认配置,照常启动;
- 文件写错 → **报错退出并指出行号**,原文件不会被修改。例如: - 文件写错 → **报错退出并指出行号**,原文件不会被修改。例如:
``` ```
[代打台] 配置文件有问题,已停止启动: [代打台-L] 配置文件有问题,已停止启动:
第 3 行: listen_port 必须在 1-65535 之间,当前是 99999 第 3 行: listen_port 必须在 1-65535 之间,当前是 99999
修好后重新启动即可(原文件未被修改) 修好后重新启动即可(原文件未被修改)
@@ -80,31 +84,31 @@
示例:`daili.exe -addr 127.0.0.1:19000 -no-open` 示例:`daili.exe -addr 127.0.0.1:19000 -no-open`
> 业务参数(到期预警天数、大屏刷新秒数)不在配置文件里,仍在网页「设置」中修改。 > 业务参数(到期预警天数)不在配置文件里,仍在网页「设置」中修改。
--- ---
## 3. 第一次使用(5 分钟走完) ## 3. 第一次使用(5 分钟走完)
1. **建套餐**:`套餐 → 新增套餐` 1. **建套餐**:`套餐 → 新增套餐`
- 「包月日常」按日 30 天,色签绿; - 「包月日常」默认 30 天,色签绿;
- 「深渊十次」按次 10 次,色签紫。 - 只有「按日」一种计费方式:有开始日与结束日,每天一条待打任务。
> 以后要加套餐 D/E/F,直接再加一行就行,**不会影响任何已有客户数据**。 > 以后要加套餐 D/E/F,直接再加一行就行,**不会影响任何已有客户数据**。
2. **建客户**:`客户 → 新建客户`,填手机尾号 + 客户名(这两项组合唯一)。 2. **建客户**:`客户 → 新建客户`,填手机尾号 + 客户名(这两项组合唯一)。
3. **开通订阅**:进客户详情 → 选套餐、开始日、天数(按次则填次数)→ 开通订阅。 3. **开通订阅**:进客户详情 → 选套餐、开始日、天数 → 开通订阅。
4. **日常使用**:打开「日历」,今天那格里就是这个客户今天要打的单,点「完成」打卡。 4. **日常使用**:打开「日历」,今天那格里就是这个客户今天要打的单,点「完成」打卡。
5. **续费**:客户转账后,点订阅上的「追加时长」→ 在日历上点新的终止日期 → 确认。 5. **续费**:客户转账后,点订阅上的「追加时长」→ 在日历上点新的终止日期 → 确认。
面板会实时显示「计算起点 / 原结束日 / 新结束日 / 本次追加几天 / 追加后剩余几天」,并在客户页留下追加记录。 面板会实时显示「计算起点 / 原结束日 / 新结束日 / 本次追加几天 / 追加后剩余几天」,并在客户页留下追加记录。
> 想先看效果再录自己的数据?程序启动后运行 `powershell -ExecutionPolicy Bypass -File scripts\seed.ps1`,会写入 8 个演示客户和 4 个套餐(默认账号 `admin` / `daili123`)。 > 想先看效果再录自己的数据?程序启动后运行 `powershell -ExecutionPolicy Bypass -File scripts\seed.ps1`,会写入演示客户与套餐(默认账号 `admin` / `daili123`)。
--- ---
## 4. 业务规则(和 PRD 一致) ## 4. 业务规则(和 PRD 一致)
**剩余时长** **剩余时长**
- 按日:`剩余天数 = 结束日 − 今天 + 1`,结束日当天仍算服务日; - `剩余天数 = 结束日 − 今天 + 1`,结束日当天仍算服务日;
- 按次:`剩余次数 = 总次数 − 已用次数`;按次套餐的「有效期」可留空,留空就一直有效直到次数用完。 - 没有次数、没有周期:一个订阅就是从开始日到结束日的一段连续服务期。
**追加时长(核心)** **追加时长(核心)**
- 打开追加面板,**在月历上点选新的终止日期**,系统自动反算; - 打开追加面板,**在月历上点选新的终止日期**,系统自动反算;
@@ -114,15 +118,14 @@
**暂停 / 恢复** **暂停 / 恢复**
- 暂停期间冻结剩余时长,日历上不出现待打任务; - 暂停期间冻结剩余时长,日历上不出现待打任务;
- 恢复时按日套餐的结束日**自动顺延**暂停的自然日天数,时间轴上是灰色斜纹段。 - 恢复时结束日**自动顺延**暂停的自然日天数,时间轴上是灰色斜纹段。
**打卡** **打卡**
- 按日套餐:每天一条「完成 / 跳过」,只做记录不扣天数(天数按自然日流逝); - 每天一条「完成 / 跳过」,只做记录不扣天数(天数按自然日流逝);
- 按次套餐:打卡「完成」自动扣 1 次,撤销打卡返还 1 次;次数为 0 时不允许再打; - 撤销打卡会删掉这条记录,不影响结束日。
- 按次套餐如果没设有效期,它不会出现在日历里 —— 直接在客户详情点订阅卡片上的「今日打卡」扣次。
**预警** **预警**
- 剩余 ≤3 天橙色、今天到期红色、已过期灰色;顶栏常驻「今日到期 / 3 天内」两个数字。 - 剩余 ≤3 天橙色、今天到期红色、已过期灰色;顶栏常驻「今日到期 / 3 天内 / 7 天内」三个数字。
**查询** **查询**
- 顶栏搜索框输入手机尾号即可定位客户(也支持姓名、游戏名、联系方式),按 `/` 快速聚焦。 - 顶栏搜索框输入手机尾号即可定位客户(也支持姓名、游戏名、联系方式),按 `/` 快速聚焦。
@@ -134,41 +137,24 @@
- **按尾号快速完成**:日历右侧「当日待完成」顶部输入手机尾号 → 回车。 - **按尾号快速完成**:日历右侧「当日待完成」顶部输入手机尾号 → 回车。
- 只匹配到 1 项:直接完成并提示; - 只匹配到 1 项:直接完成并提示;
- 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。 - 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。
- **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。 - **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点。再点一次取消。
- **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。 - **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。
- **触摸小键盘**:日历右侧输入框下面有 3 列 4 行数字键盘,底行是 ⌫ / 0 / ✓,点 ✓ 直接提交;按键为自定义样式,不是浏览器原生按钮,触摸目标 52px 高; - **日历格子**:只保留大字「已完成 / 总数」(如 1/2 = 2 单里做完 1 单)。
- **日历格子**:不再显示排单缩略信息,只保留左侧打手色竖条 + 大字「待完成 / 总数」(如 1/2 = 还剩 1 单,共 2 单); - **按钮收纳**:任务行只露出「完成」,跳过 / 加急 / 追加收进「⋯」菜单,点空白处自动收起。
- **按钮收纳**:任务行只露出「完成」,跳过 / 加急 / 追加收进「⋯」菜单,点空白处自动收起; - **停用客户**:客户详情页可「停用」(默认从列表与日历隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复;
- **停用客户**:客户详情页可「停用」(默认从列表、日历、大屏隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复; - **7 天内到期**:顶栏徽标,固定统计未来 7 天内到期的订阅;
- **7 天内到期**:顶栏新增徽标,固定统计未来 7 天内到期的订阅; - **抽屉时钟**:右侧当日面板顶部有时钟,24 小时制精确到秒,下面一行是当天日期;
- **抽屉时钟**:右侧当日面板顶部有时钟,30px 大写、水平垂直居中,24 小时制精确到秒,下面一行是当天日期; - **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余,以及撤销/加急/追加;未完成的行不受影响;
- **触控 / PC 模式**:尾号输入框右侧有「⌨ / 触控」开关。切到触控时展开 3×4 小键盘、输入框变只读并关闭系统输入法(`inputmode=none`),点键直接写值;切回 PC 收起键盘。选择记在浏览器里。 - **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;没选过时由页面在首屏按系统偏好自动决定。整套界面只认 `<html data-bs-theme>` 一个开关,不会出现「半亮半暗」。
- **格子计数**:工作台和大屏的日历格都显示「已完成 / 总数」,例如 2/5 = 5 单里做完 2 单。 - **升级不缓存旧界面**:CSS/JS 链接带**内容指纹**(`app.css?v=<12 位哈希>`)。资源可以长缓存(一年、immutable),但样式或脚本一改、重新编译,URL 就会变,浏览器自动取新文件,不用手动清缓存;HTML 仍然 `no-store`。
- **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余、打手,以及撤销/加急/追加;未完成的行不受影响;
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
- 「当日待完成」每条前面有打手色条 + 名字,未指派的显示灰色「未指派」;
- 大屏的今日待打、悬停浮层、热力格竖条同样带颜色。
- **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;不选时跟随系统。
- **升级不缓存旧界面**:CSS/JS 链接带版本号(`app.css?v=1.6.0`),换新版程序后浏览器会自动取新文件,不用手动清缓存。
---
## 4.3 包赛季与按次有效期
- **包赛季**:套餐计费方式新增第三种「包赛季」,套餐上直接填赛季开始日/结束日。客户开通后,日历上用一条横向色带标出整段赛季;赛季内任意一天点「完成」即整段完成,完成后不再出现在日历与待打,客户详情显示「赛季已完成 + 日期」。
- **赛季过期不可选**:今天已过赛季结束日的套餐,在客户开通下拉里会置灰并标「赛季已结束」,服务端也会拒绝开通。
- **按次默认有效期**:按次套餐可设「默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可手动改。
- **删除订阅**:已结束的订阅卡片上,在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加记录。
--- ---
## 4.2 套餐标签与批量开通 ## 4.2 套餐标签与批量开通
- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。 - **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。
- **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数/次数后一次开通;重复的会自动跳过并提示。 - **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数后一次开通;重复的会自动跳过并提示。
- **客户月历**:客户详情页新增「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。 - **客户月历**:客户详情页「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。
- **按次到期**:按次订阅的「到期日」仍是选填;填了到期后状态显示「已到期」、不再进日历待排。
--- ---
@@ -183,8 +169,9 @@
### 迁移与重置(只说一次,很重要) ### 迁移与重置(只说一次,很重要)
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。 - **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。 - **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建管理员」的全新状态。
- 包赛季是 `packages.season/season_start/season_end` 与 `subscriptions.season/season_done`;套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; - 套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);加急标记存在 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移。
- **从 main 升级**:lite 版启动时会自动把老库里的「按次 / 通行证 / 深渊」统一折算成按日(详见文末差异说明),老库直接打开即可,无需手工改数据。
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。 - `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库; - 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
@@ -192,22 +179,7 @@
--- ---
## 6. 大屏 ## 6. 从源码构建
- 地址:`http://<本机IP>:17777/screen`
- 默认 `screen_public = true`:**免登录**,电视/副屏直接打开即可(仅建议局域网使用);
- 改成 `screen_public = false` 后大屏回到登录保护;
- 「记分板」风格,亮 / 暗主题同样可切(右上角 ☾ / ☀):
- 顶部 KPI:客户 / 活跃订阅 / 今日到期 / 7 天内到期;
- 中部:**热力月历 + 即将到期 TOP10**。热力默认统计「每日待完成单量」,点右上角按钮可切回「到期热力」;鼠标悬停任意格子会浮出当日待办明细(含加急标记);
- 底部:**今日到期**(左)+ **今日待打**(右,加急置顶带红 tag);
- 最下方细条:最近追加流水;
- 套餐勾选「在日历中独立显示」后,大屏顶部会出现套餐标签,可切换只看某类委托;
- 默认 30 秒自动刷新(设置页可改),按 `F` 全屏。
---
## 7. 从源码构建
环境:Windows + Go 1.25(本项目在 `go1.25.6` 上验证)。 环境:Windows + Go 1.25(本项目在 `go1.25.6` 上验证)。
@@ -232,6 +204,12 @@ $env:GOFLAGS='-mod=mod'; $env:GOPROXY='off'; $env:GOCACHE="$PWD\.gocache"
go test ./... go test ./...
``` ```
页面级冒烟(起一个隔离实例,把所有页面渲染一遍,并校验水印/品牌/已移除入口):
```powershell
powershell -ExecutionPolicy Bypass -File scripts\smoke-lite.ps1
```
目录结构: 目录结构:
``` ```
@@ -243,10 +221,10 @@ internal/logx 控制台 + 按天切分的运行日志
internal/pw 口令散列(PBKDF2-SHA256,标准库实现) internal/pw 口令散列(PBKDF2-SHA256,标准库实现)
internal/web 路由、会话、模板渲染、处理器 internal/web 路由、会话、模板渲染、处理器
internal/web/templates 页面与布局(embed 进二进制) internal/web/templates 页面与布局(embed 进二进制)
internal/web/static app.css / screen.css / app.js / screen.js internal/web/static app.css / app.js
scripts/acceptance.ps1 冒烟清单执行器(119 条断言) scripts/smoke-lite.ps1 lite 版页面级冒烟(渲染 + 水印 + 已移除入口 404)
scripts/config-test.ps1 配置文件专项测试(14 条断言) scripts/acceptance.ps1 冒烟清单执行器(main 分支遗留,部分条目已不适用)
scripts/smoke.ps1 端到端冒烟(早期版本,保留) scripts/config-test.ps1 配置文件专项测试
scripts/seed.ps1 写入演示数据 scripts/seed.ps1 写入演示数据
scripts/dbtool.py 测试预处理(暂停回溯、批量造数、完整性检查) scripts/dbtool.py 测试预处理(暂停回溯、批量造数、完整性检查)
.gitea/workflows/build.yml Gitea Actions:vet → test → 交叉编译 → 上传产物 .gitea/workflows/build.yml Gitea Actions:vet → test → 交叉编译 → 上传产物
@@ -272,7 +250,7 @@ daili.ini 首次运行自动生成
--- ---
## 8. 常见问题 ## 7. 常见问题
**Windows 提示「已保护你的电脑」** **Windows 提示「已保护你的电脑」**
点「更多信息 → 仍要运行」。这是 Windows 对未签名 exe 的默认拦截。 点「更多信息 → 仍要运行」。这是 Windows 对未签名 exe 的默认拦截。
@@ -290,26 +268,55 @@ daili.ini 首次运行自动生成
4. 用启动时打印的那个 `192.168.x.x` 地址,不要用 `127.0.0.1`。 4. 用启动时打印的那个 `192.168.x.x` 地址,不要用 `127.0.0.1`。
**忘记密码** **忘记密码**
让另一位管理员在 `设置 → 管理员` 里新增一个账户即可;如果只剩一个管理员且忘了密码,只能删掉 `daili.db` 重来。 lite 版只有一个管理员,没有「新增管理员」可以绕过,只能二选一:删掉 `daili.db` 从零开始,或用任意 SQLite 工具把 `users` 表的 `password_hash` 清空后重新初始化。请务必记牢密码。
**数据会不会因为程序崩溃丢?** **数据会不会因为程序崩溃丢?**
单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。 单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。
**能放到公网吗?** **能放到公网吗?**
不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public` 改成 `false`。 不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定。要放公网请套一层反向代理 + HTTPS + 访问控制。
--- ---
## 9. 安全说明 ## 8. 安全说明
- **初始管理员**(第一个创建的账号,`id=1`)在 `设置 → 管理员` 每行可以点「编辑」,修改其他管理员的用户名、昵称,或重置密码;
- 重置密码会立刻清除对方的登录会话,他需要用新密码重新登录;昵称/用户名的修改不影响会话;
- 自己那一行不显示编辑(改自己的密码用「修改我的密码」),避免误操作把自己锁在门外;
- 其余管理员权限相同,但只能改自己的密码。
- lite 版**只有一个管理员**,就是首次启动时创建的那个账户(`users.id = 1`),不提供新增、停用或编辑他人账户的入口;
- 改自己的密码用 `设置 → 修改我的密码`;
- 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆; - 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆;
- 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询; - 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询;
- 服务端模板全部走 `html/template` 自动转义; - 服务端模板全部走 `html/template` 自动转义;
- 所有 POST 校验 `Origin`,会话 Cookie 使用 `SameSite=Lax` + `HttpOnly`; - 所有 POST 校验 `Origin`,会话 Cookie 使用 `SameSite=Lax` + `HttpOnly`;
- 配置文件里的数据库/备份/日志路径请自行确保目录权限,程序不会覆盖已有文件(备份重名会跳过)。 - 配置文件里的数据库/备份/日志路径请自行确保目录权限,程序不会覆盖已有文件(备份重名会跳过)。
---
## 9. 盲水印
界面里带一处不可见的版权水印 `Powered by Baka春秋`:写在 `_layout.html` 里由 `.wm-blind` 样式隐藏(零尺寸、透明、`clip-path` 裁掉、不进入无障碍树),同时在 `app.css` 里留了同名注释。它不占位、不上色、不影响布局与打印,只用于源码/DOM 层面溯源,**请勿删除**。
页脚另有一行可见署名 `Powered by Baka春秋`。
---
## 10. 与 main 分支的差异
| 项目 | main | lite(本分支) |
| --- | --- | --- |
| 版本号 | `1.11.5` | `1.11.5-L`(`<title>`、启动横幅、日志前缀、页脚全站统一带 `-L`) |
| 品牌 | 代打台 | 代打台-L |
| 大屏 `/screen` | 有,`screen_public` 控制免登录 | **已移除**,路由返回 404 |
| 客户自助 `/customer` | 有,`portal_public` 控制免登录 | **已移除**,路由返回 404 |
| 套餐计费方式 | 按日 / 按次 / 通行证 / 深渊 | **只有按日** |
| 管理员 | 可新增多个,为主管可编辑他人 | **只有首次启动创建的那一个** |
| 打手归属 | 客户可指派打手,彩色竖条标识 | **已移除**(只剩一个人,颜色无意义) |
| 触摸小键盘 | 3×4 小键盘 + 触控/PC 切换 | **已移除** |
| 署名 | 无 | 页脚 + 盲水印 `Powered by Baka春秋` |
**老数据怎么折算成按日**(启动时自动完成,可重复执行):
- **通行证**:当年就是 `unit='day'` 且起止日期完整,清掉 `season` 标记即可,服务区间原样保留;
- **深渊**:同样已是 `unit='day'`,结束日 = 开始日 + 周期天数 × 周期数 − 1,清掉 `abyss/cycle_days` 标记,区间原样保留;
- **按次**:按「1 次 = 1 天」折算。有总次数但没结束日的,结束日 = 开始日 + 总次数 − 1 天;仍缺结束日的退回开始日;
- 折算只动数据,不删表、不删列:老库里的 `season / abyss / cycle_days / total_count / used_count` 等列会保留但不再使用,避免升级过程不可逆。
`screen_public` 与 `portal_public` 两个配置项在 lite 版仍然被解析(不报错),但不再有任何作用。
+17 -3
View File
@@ -1,7 +1,7 @@
// Package config 负责读取/生成与可执行文件同级的 daili.ini。 // Package config 负责读取/生成与可执行文件同级的 daili.ini。
// //
// 设计约定: // 设计约定:
// - 只放「部署类」参数;业务默认值(预警天数、大屏刷新秒数)仍在设置页(数据库)。 // - 只放「部署类」参数;业务默认值(预警天数等)仍在设置页(数据库)。
// - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。 // - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。
// - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。 // - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。
package config package config
@@ -33,7 +33,10 @@ type Config struct {
BackupKeep int BackupKeep int
Timezone string Timezone string
OpenBrowser bool OpenBrowser bool
// ScreenPublic / PortalPublic 在 lite 版已废弃:仍然解析,但不再有任何作用,
// 只为兼容旧版 daili.ini(否则旧文件会因“未知配置项”直接启动失败)。
ScreenPublic bool ScreenPublic bool
PortalPublic bool
LogEnabled bool LogEnabled bool
LogDir string LogDir string
@@ -57,6 +60,7 @@ func Defaults() *Config {
Timezone: "", Timezone: "",
OpenBrowser: true, OpenBrowser: true,
ScreenPublic: true, ScreenPublic: true,
PortalPublic: true,
LogEnabled: true, LogEnabled: true,
LogDir: "logs", LogDir: "logs",
} }
@@ -203,6 +207,12 @@ func (c *Config) set(key, val string) error {
return fmt.Errorf("screen_public %v", err) return fmt.Errorf("screen_public %v", err)
} }
c.ScreenPublic = b 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": case "log_enabled":
b, err := parseBool(val) b, err := parseBool(val)
if err != nil { if err != nil {
@@ -271,7 +281,7 @@ func parseBool(v string) (bool, error) {
func templateText(c *Config) string { func templateText(c *Config) string {
return fmt.Sprintf(`# ============================================================ return fmt.Sprintf(`# ============================================================
# 代打台 配置文件(daili.ini) # 代打台-L 配置文件(daili.ini)
# 与 daili.exe 放在同一目录;修改后需要重启程序生效。 # 与 daili.exe 放在同一目录;修改后需要重启程序生效。
# 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。 # 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。
# 相对路径相对于 exe 所在目录,而不是当前工作目录。 # 相对路径相对于 exe 所在目录,而不是当前工作目录。
@@ -304,9 +314,12 @@ timezone = %s
# 启动后自动打开浏览器 # 启动后自动打开浏览器
open_browser = %t open_browser = %t
# 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录) # 以下两项在 lite 版已废弃(本版没有大屏 /screen 与客户自助 /customer)。
# 保留是为了让旧版 daili.ini 仍能直接启动;写了也不报错、不生效。
screen_public = %t screen_public = %t
portal_public = %t
# ---------- 日志 ---------- # ---------- 日志 ----------
# 运行日志同时输出到控制台与 log_dir 下按天切分的文件 # 运行日志同时输出到控制台与 log_dir 下按天切分的文件
log_enabled = %t log_enabled = %t
@@ -322,6 +335,7 @@ log_dir = %s
c.Timezone, c.Timezone,
c.OpenBrowser, c.OpenBrowser,
c.ScreenPublic, c.ScreenPublic,
c.PortalPublic,
c.LogEnabled, c.LogEnabled,
c.LogDir, c.LogDir,
) )
+1 -1
View File
@@ -46,7 +46,7 @@ func TestLoadCreatesDefaultFile(t *testing.T) {
t.Fatalf("默认配置文件未生成: %v", err) t.Fatalf("默认配置文件未生成: %v", err)
} }
text := string(data) text := string(data)
for _, want := range []string{"listen_ip = 0.0.0.0", "listen_port = 17777", "代打台 配置文件(daili.ini)", "timezone ="} { for _, want := range []string{"listen_ip = 0.0.0.0", "listen_port = 17777", "代打台-L 配置文件(daili.ini)", "timezone ="} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Errorf("默认配置缺少 %q", want) t.Errorf("默认配置缺少 %q", want)
} }
+18 -41
View File
@@ -1,4 +1,7 @@
// Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。 // Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。
//
// lite 版只支持「按日(包时)」一种套餐:订阅永远有开始日与结束日,
// 剩余量永远是天数,因此这里不再有次数、周期、通行证等分支。
package domain package domain
import "time" import "time"
@@ -68,19 +71,12 @@ const (
StatusClosed = "closed" StatusClosed = "closed"
) )
// 套餐类型
const (
UnitDay = "day"
UnitCount = "count"
)
// 派生展示状态 // 派生展示状态
const ( const (
StateActive = "active" // 进行中 StateActive = "active" // 进行中
StatePending = "pending" // 未开始 StatePending = "pending" // 未开始
StatePaused = "paused" // 暂停中 StatePaused = "paused" // 暂停中
StateExpired = "expired" // 已到期 StateExpired = "expired" // 已到期
StateUsedUp = "usedup" // 次数用尽
StateClosed = "closed" // 已结束 StateClosed = "closed" // 已结束
) )
@@ -90,22 +86,20 @@ const (
LevelWarn = "warn" LevelWarn = "warn"
LevelToday = "today" LevelToday = "today"
LevelExpired = "expired" LevelExpired = "expired"
LevelNone = "none" // 无期限(按次且未设有效期) LevelNone = "none" // 无结束日(历史数据兜底)
) )
// Eval 是订阅的计算结果。 // Eval 是订阅的计算结果。
type Eval struct { type Eval struct {
State string State string
DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日 DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日
RemainingDays int RemainingDays int
RemainingCount int Level string
Level string
} }
// EvalSub 计算订阅的展示状态与剩余量。 // EvalSub 计算订阅的展示状态与剩余天数。
// - unit=day:剩余天数 = end - today + 1(含当天) // 剩余天数 = end - today + 1(含当天)。
// - unit=count:剩余次数 = total - used;end 为可选有效期 func EvalSub(status, startDate, endDate string, today time.Time, warnDays int) Eval {
func EvalSub(unit, status, startDate, endDate string, total, used int, today time.Time, warnDays int) Eval {
e := Eval{Level: LevelNone, DaysLeft: -1} e := Eval{Level: LevelNone, DaysLeft: -1}
if warnDays <= 0 { if warnDays <= 0 {
warnDays = 3 warnDays = 3
@@ -119,27 +113,14 @@ func EvalSub(unit, status, startDate, endDate string, total, used int, today tim
} }
} }
if unit == UnitDay { if hasEnd {
if hasEnd { e.RemainingDays = e.DaysLeft + 1
e.RemainingDays = e.DaysLeft + 1 }
} if e.RemainingDays < 0 {
if e.RemainingDays < 0 { e.RemainingDays = 0
e.RemainingDays = 0
}
} else {
e.RemainingCount = total - used
if e.RemainingCount < 0 {
e.RemainingCount = 0
}
} }
expired := false expired := hasEnd && end.Before(today)
if hasEnd && end.Before(today) {
expired = true
}
if unit == UnitCount && e.RemainingCount <= 0 {
expired = true
}
switch { switch {
case status == StatusClosed: case status == StatusClosed:
@@ -147,11 +128,7 @@ func EvalSub(unit, status, startDate, endDate string, total, used int, today tim
case status == StatusPaused: case status == StatusPaused:
e.State = StatePaused e.State = StatePaused
case expired: case expired:
if unit == UnitCount && e.RemainingCount <= 0 { e.State = StateExpired
e.State = StateUsedUp
} else {
e.State = StateExpired
}
case start.After(today): case start.After(today):
e.State = StatePending e.State = StatePending
default: default:
+9 -30
View File
@@ -46,7 +46,7 @@ func TestEvalDay(t *testing.T) {
{"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30}, {"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30},
} }
for _, c := range cases { for _, c := range cases {
e := EvalSub(UnitDay, c.status, "2026-10-01", c.end, 0, 0, today, 3) e := EvalSub(c.status, "2026-10-01", c.end, today, 3)
if e.State != c.wantState { if e.State != c.wantState {
t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState) t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState)
} }
@@ -61,7 +61,7 @@ func TestEvalDay(t *testing.T) {
func TestEvalDayNotStarted(t *testing.T) { func TestEvalDayNotStarted(t *testing.T) {
today := d(2026, 10, 18) today := d(2026, 10, 18)
e := EvalSub(UnitDay, "active", "2026-10-20", "2026-11-18", 0, 0, today, 3) e := EvalSub("active", "2026-10-20", "2026-11-18", today, 3)
if e.State != StatePending { if e.State != StatePending {
t.Fatalf("state=%s want pending", e.State) t.Fatalf("state=%s want pending", e.State)
} }
@@ -70,37 +70,16 @@ func TestEvalDayNotStarted(t *testing.T) {
} }
} }
func TestEvalCount(t *testing.T) { // TestEvalNoEndDate 覆盖历史数据里结束日为空的兜底分支(lite 版没有按次计费)。
func TestEvalNoEndDate(t *testing.T) {
today := d(2026, 10, 18) today := d(2026, 10, 18)
// 有次数、有有效期,10 天后到期 → 正常 e := EvalSub("active", "2026-10-01", "", today, 3)
e := EvalSub(UnitCount, "active", "2026-10-01", "2026-10-28", 10, 4, today, 3) if e.Level != LevelNone || e.DaysLeft != -1 || e.RemainingDays != 0 {
if e.State != StateActive || e.Level != LevelOK || e.RemainingCount != 6 { t.Fatalf("level=%s daysLeft=%d remain=%d", e.Level, e.DaysLeft, e.RemainingDays)
t.Fatalf("got state=%s level=%s remain=%d", e.State, e.Level, e.RemainingCount)
} }
if e.State != StateActive {
// 有效期就是今天 → 今天到期(而不是误判成 warn/ok) t.Fatalf("state=%s want active", e.State)
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-18", 10, 4, today, 3)
if e.Level != LevelToday {
t.Fatalf("level=%s want today", e.Level)
}
// 只有次数,没有有效期 → 无预警等级
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 4, today, 3)
if e.Level != LevelNone || e.DaysLeft != -1 {
t.Fatalf("level=%s daysLeft=%d", e.Level, e.DaysLeft)
}
// 次数用尽
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 10, today, 3)
if e.State != StateUsedUp {
t.Fatalf("state=%s want usedup", e.State)
}
// 次数还有,但有效期已过
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-10", 10, 4, today, 3)
if e.State != StateExpired {
t.Fatalf("state=%s want expired", e.State)
} }
} }
+19 -66
View File
@@ -20,7 +20,7 @@ type Checkin struct {
UpdatedAt string UpdatedAt string
} }
// SaveCheckin 打卡(同订阅同日覆盖),按次套餐完成时扣次。 // SaveCheckin 打卡(同订阅同日覆盖)。
func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error { func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error {
if status != "done" && status != "skipped" { if status != "done" && status != "skipped" {
return errors.New("打卡状态不正确") return errors.New("打卡状态不正确")
@@ -36,91 +36,39 @@ func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int
if s.Status == domain.StatusClosed { if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法打卡") return errors.New("订阅已结束,无法打卡")
} }
var oldStatus string now := nowStamp()
err = tx.QueryRow(`SELECT status FROM checkins WHERE sub_id=? AND service_date=?`, subID, date).Scan(&oldStatus) res, err := tx.Exec(`UPDATE checkins SET status=?, note=?, operator_id=?, updated_at=? WHERE sub_id=? AND service_date=?`,
hasOld := err == nil status, note, nullIfZero(operatorID), now, subID, date)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil {
return err return err
} }
if n, _ := res.RowsAffected(); n > 0 {
delta := 0 return nil
if s.Unit == domain.UnitCount {
oldDone := hasOld && oldStatus == "done"
newDone := status == "done"
switch {
case newDone && !oldDone:
if s.UsedCount+1 > s.TotalCount {
return errors.New("剩余次数不足,请先追加次数")
}
delta = 1
case !newDone && oldDone:
delta = -1
}
} }
_, err = tx.Exec(`INSERT INTO checkins(sub_id,service_date,status,note,operator_id,created_at,updated_at)
now := nowStamp() VALUES(?,?,?,?,?,?,?)`, subID, date, status, note, nullIfZero(operatorID), now, now)
if hasOld { return err
if _, err := tx.Exec(`UPDATE checkins SET status=?, note=?, operator_id=?, updated_at=? WHERE sub_id=? AND service_date=?`,
status, note, nullIfZero(operatorID), now, subID, date); err != nil {
return err
}
} else {
if _, err := tx.Exec(`INSERT INTO checkins(sub_id,service_date,status,note,operator_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, subID, date, status, note, nullIfZero(operatorID), now, now); err != nil {
return err
}
}
if delta != 0 {
newUsed := s.UsedCount + delta
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`, newUsed, now, subID); err != nil {
return err
}
}
return nil
}) })
} }
// DeleteCheckin 撤销打卡(按次套餐返还次数),返回所属订阅 ID。 // DeleteCheckin 撤销打卡,返回所属订阅 ID。
func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) { func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
var subID int64 var subID int64
var status string
var date string
err := db.withTx(func(tx *sql.Tx) error { err := db.withTx(func(tx *sql.Tx) error {
if err := tx.QueryRow(`SELECT sub_id,status,service_date FROM checkins WHERE id=?`, id). if err := tx.QueryRow(`SELECT sub_id FROM checkins WHERE id=?`, id).Scan(&subID); err != nil {
Scan(&subID, &status, &date); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound return ErrNotFound
} }
return err return err
} }
if _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id); err != nil { _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id)
return err return err
}
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit == domain.UnitCount && status == "done" {
newUsed := s.UsedCount - 1
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`,
newUsed, nowStamp(), subID); err != nil {
return err
}
}
return nil
}) })
return subID, err return subID, err
} }
// CheckinsBetween 取区间内打卡,索引为 [subID][date]。 // CheckinsBetween 取区间内打卡,索引为 [subID][date]。
func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, error) { 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), 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 IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
@@ -128,6 +76,11 @@ func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, er
if err != nil { if err != nil {
return nil, err 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() defer rows.Close()
for rows.Next() { for rows.Next() {
var c Checkin var c Checkin
+16 -39
View File
@@ -23,11 +23,6 @@ type Customer struct {
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
// 归属打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
// Archived 停用(软删除):默认隐藏,订阅保留但不上日历 // Archived 停用(软删除):默认隐藏,订阅保留但不上日历
Archived bool Archived bool
@@ -49,14 +44,13 @@ func (c *Customer) TagList() []string {
return out return out
} }
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at,IFNULL(assignee_id,0),IFNULL((SELECT u.display_name FROM users u WHERE u.id=customers.assignee_id),''),IFNULL((SELECT u.color FROM users u WHERE u.id=customers.assignee_id),''),IFNULL(archived,0)` const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at,IFNULL(archived,0)`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) { func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer var c Customer
var archived int var archived int
err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact, err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact,
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt, &c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt, &archived)
&c.AssigneeID, &c.AssigneeName, &c.AssigneeColor, &archived)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -70,10 +64,10 @@ func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
// CreateCustomer 新建客户(尾号+姓名唯一)。 // CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) { func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp() now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,assignee_id,created_at,updated_at) res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact, strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), now, now) c.Tags, c.Source, c.Note, now, now)
if err != nil { if err != nil {
if isUnique(err) { if isUnique(err) {
return 0, ErrDuplicate return 0, ErrDuplicate
@@ -85,10 +79,10 @@ func (db *DB) CreateCustomer(c *Customer) (int64, error) {
// UpdateCustomer 更新客户。 // UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error { func (db *DB) UpdateCustomer(c *Customer) error {
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,assignee_id=?,updated_at=? _, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
WHERE id=?`, WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact, strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), nowStamp(), c.ID) c.Tags, c.Source, c.Note, nowStamp(), c.ID)
if err != nil && isUnique(err) { if err != nil && isUnique(err) {
return ErrDuplicate return ErrDuplicate
} }
@@ -157,7 +151,7 @@ func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays
return out, total, nil return out, total, nil
} }
// summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。 // summarizeSubs 生成「包月日常 剩12天 · 深渊日常 今天到期」式的摘要与最紧急等级。
func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) { func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) {
if len(subs) == 0 { if len(subs) == 0 {
return "暂无订阅", domain.LevelNone return "暂无订阅", domain.LevelNone
@@ -172,24 +166,13 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
continue continue
} }
var text string var text string
if s.Unit == domain.UnitDay { switch {
switch { case e.State == domain.StateExpired:
case e.State == domain.StateExpired: text = s.PackageName + " 已过期"
text = s.PackageName + " 已过期" case e.DaysLeft == 0:
case e.DaysLeft == 0: text = s.PackageName + " 今天到期"
text = s.PackageName + " 今天到期" default:
default: text = fmt.Sprintf("%s 剩%d天", s.PackageName, e.RemainingDays)
text = fmt.Sprintf("%s 剩%d天", s.PackageName, e.RemainingDays)
}
} else {
switch {
case e.RemainingCount <= 0:
text = s.PackageName + " 已用尽"
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
default:
text = fmt.Sprintf("%s 剩%d次", s.PackageName, e.RemainingCount)
}
} }
parts = append(parts, text) parts = append(parts, text)
if rank[e.Level] > rank[worst] { if rank[e.Level] > rank[worst] {
@@ -205,12 +188,6 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
return strings.Join(parts, " · "), worst return strings.Join(parts, " · "), worst
} }
// FindCustomerByTailName 精确查找(用于查重提示)。
func (db *DB) FindCustomerByTailName(tail, name string) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE tail=? AND name=?`,
strings.TrimSpace(tail), strings.TrimSpace(name)))
}
// CustomerCount 客户总数。 // CustomerCount 客户总数。
func (db *DB) CustomerCount() (int, error) { func (db *DB) CustomerCount() (int, error) {
var n int var n int
@@ -251,7 +228,7 @@ func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) {
return out, rows.Err() return out, rows.Err()
} }
// SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历与大屏。 // SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历。
func (db *DB) SetCustomerArchived(id int64, archived bool) error { func (db *DB) SetCustomerArchived(id int64, archived bool) error {
v := 0 v := 0
if archived { if archived {
+66 -62
View File
@@ -102,13 +102,6 @@ func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
} }
func normalizeUnit(u string) string {
if u == domain.UnitCount {
return domain.UnitCount
}
return domain.UnitDay
}
// Migrate 幂等建表。 // Migrate 幂等建表。
func (db *DB) Migrate() error { func (db *DB) Migrate() error {
for _, stmt := range schemaStatements { for _, stmt := range schemaStatements {
@@ -121,74 +114,98 @@ func (db *DB) Migrate() error {
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil { "ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
return err return err
} }
// v1.3:管理员颜色 + 客户归属打手 // v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
if err := db.ensureColumn("users", "color", if err := db.ensureColumn("packages", "tag",
"ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT ''"); err != nil { "ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("customers", "assignee_id",
"ALTER TABLE customers ADD COLUMN assignee_id INTEGER REFERENCES users(id)"); err != nil {
return err return err
} }
if err := db.ensureColumn("customers", "archived", if err := db.ensureColumn("customers", "archived",
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil { "ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
return err return err
} }
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签) // lite:把老库里按次 / 通行证 / 深渊的数据统一折算成按日
if err := db.ensureColumn("packages", "tag", return db.normalizeLegacyData()
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil { }
return err
// normalizeLegacyData 把 main 分支遗留的三种套餐类型折算成按日。
//
// - 通行证、深渊订阅当年就是 unit='day' 且起止日期完整,清掉标记即可;
// - 按次订阅按「1 次 = 1 天」折算成天:有 total_count 且没有结束日的,
// 结束日 = 开始日 + total_count - 1;仍缺结束日的退回开始日。
//
// 老库缺少这些列时自动跳过,因此可以反复执行。
func (db *DB) normalizeLegacyData() error {
// 套餐:一律按日
pkgSets := []string{"unit='day'"}
for _, col := range []string{"season", "abyss", "cycle_days", "default_validity_days"} {
if db.hasColumn("packages", col) {
pkgSets = append(pkgSets, col+"=0")
}
} }
// v1.8:包赛季套餐 + 按次默认有效期 for _, col := range []string{"season_start", "season_end"} {
if err := db.ensureColumn("packages", "season", if db.hasColumn("packages", col) {
"ALTER TABLE packages ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil { pkgSets = append(pkgSets, col+"=''")
return err }
} }
if err := db.ensureColumn("packages", "default_validity_days", if _, err := db.sql.Exec(`UPDATE packages SET ` + strings.Join(pkgSets, ",")); err != nil {
"ALTER TABLE packages ADD COLUMN default_validity_days INTEGER NOT NULL DEFAULT 0"); err != nil { return fmt.Errorf("迁移套餐类型失败: %w", err)
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 db.hasColumn("subscriptions", "total_count") {
if _, err := db.sql.Exec(`UPDATE subscriptions
SET end_date = date(start_date, '+' || (total_count - 1) || ' days')
WHERE unit='count' AND IFNULL(total_count,0) > 0 AND IFNULL(end_date,'')=''`); err != nil {
return fmt.Errorf("迁移按次订阅失败: %w", err)
}
} }
if err := db.ensureColumn("packages", "season_end", if _, err := db.sql.Exec(`UPDATE subscriptions
"ALTER TABLE packages ADD COLUMN season_end TEXT NOT NULL DEFAULT ''"); err != nil { SET unit='day', end_date=IFNULL(NULLIF(end_date,''), start_date)
return err WHERE unit<>'day' OR IFNULL(end_date,'')=''`); err != nil {
return fmt.Errorf("迁移订阅计费方式失败: %w", err)
} }
if err := db.ensureColumn("subscriptions", "season", subSets := []string{}
"ALTER TABLE subscriptions ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil { for _, col := range []string{"season", "abyss", "cycle_days"} {
return err if db.hasColumn("subscriptions", col) {
subSets = append(subSets, col+"=0")
}
} }
if err := db.ensureColumn("subscriptions", "season_done", if db.hasColumn("subscriptions", "season_done") {
"ALTER TABLE subscriptions ADD COLUMN season_done TEXT NOT NULL DEFAULT ''"); err != nil { subSets = append(subSets, "season_done=''")
return err }
if len(subSets) > 0 {
if _, err := db.sql.Exec(`UPDATE subscriptions SET ` + strings.Join(subSets, ",")); err != nil {
return fmt.Errorf("迁移订阅标记失败: %w", err)
}
} }
return nil return nil
} }
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。 // hasColumn 判断表里是否有某一列。
func (db *DB) ensureColumn(table, column, ddl string) error { func (db *DB) hasColumn(table, column string) bool {
rows, err := db.sql.Query("PRAGMA table_info(" + table + ")") rows, err := db.sql.Query("PRAGMA table_info(" + table + ")")
if err != nil { if err != nil {
return fmt.Errorf("读取 %s 表结构失败: %w", table, err) return false
} }
found := false defer rows.Close()
for rows.Next() { for rows.Next() {
var cid int var cid int
var name, ctype string var name, ctype string
var notnull, pk int var notnull, pk int
var dflt any var dflt any
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil { if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close() return false
return err
} }
if strings.EqualFold(name, column) { if strings.EqualFold(name, column) {
found = true return true
} }
} }
rows.Close() return false
if found { }
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
func (db *DB) ensureColumn(table, column, ddl string) error {
if db.hasColumn(table, column) {
return nil return nil
} }
if _, err := db.sql.Exec(ddl); err != nil { if _, err := db.sql.Exec(ddl); err != nil {
@@ -204,7 +221,6 @@ var schemaStatements = []string{
display_name TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT last_login_at TEXT
)`, )`,
@@ -218,19 +234,15 @@ var schemaStatements = []string{
`CREATE TABLE IF NOT EXISTS packages ( `CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')), unit TEXT NOT NULL DEFAULT 'day' CHECK (unit IN ('day')),
default_amount INTEGER NOT NULL DEFAULT 30, default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER, price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#2F5D50', color TEXT NOT NULL DEFAULT '#3B6EA5',
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
calendar_separate INTEGER NOT NULL DEFAULT 0, calendar_separate INTEGER NOT NULL DEFAULT 0,
tag TEXT NOT NULL DEFAULT '', tag TEXT NOT NULL DEFAULT '',
season INTEGER NOT NULL DEFAULT 0,
default_validity_days INTEGER NOT NULL DEFAULT 0,
season_start TEXT NOT NULL DEFAULT '',
season_end TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
)`, )`,
@@ -244,7 +256,6 @@ var schemaStatements = []string{
tags TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
assignee_id INTEGER REFERENCES users(id),
archived INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@@ -254,14 +265,10 @@ var schemaStatements = []string{
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
package_id INTEGER NOT NULL REFERENCES packages(id), package_id INTEGER NOT NULL REFERENCES packages(id),
unit TEXT NOT NULL CHECK (unit IN ('day','count')), unit TEXT NOT NULL DEFAULT 'day' CHECK (unit IN ('day')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
start_date TEXT NOT NULL, start_date TEXT NOT NULL,
end_date TEXT, end_date TEXT,
total_count INTEGER NOT NULL DEFAULT 0,
used_count INTEGER NOT NULL DEFAULT 0,
season INTEGER NOT NULL DEFAULT 0,
season_done TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id), created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -283,9 +290,6 @@ var schemaStatements = []string{
old_end_date TEXT, old_end_date TEXT,
new_end_date TEXT, new_end_date TEXT,
days_added INTEGER NOT NULL DEFAULT 0, days_added INTEGER NOT NULL DEFAULT 0,
count_added INTEGER NOT NULL DEFAULT 0,
old_total INTEGER NOT NULL DEFAULT 0,
new_total INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
operator_id INTEGER REFERENCES users(id), operator_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL created_at TEXT NOT NULL
+5 -11
View File
@@ -38,25 +38,19 @@ func (db *DB) ExportSubscriptions(today time.Time, warnDays int) (*ExportRowSet,
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日/有效期", out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日",
"总次数", "已用", "剩余", "原文状态", "备注", "更新时间"}} "剩余", "原文状态", "备注", "更新时间"}}
stateLabel := map[string]string{ stateLabel := map[string]string{
domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中", domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中",
domain.StateExpired: "已到期", domain.StateUsedUp: "次数用尽", domain.StateClosed: "已结束", domain.StateExpired: "已到期", domain.StateClosed: "已结束",
} }
for _, s := range subs { for _, s := range subs {
e := s.Eval(today, warnDays) e := s.Eval(today, warnDays)
remaining := ""
if s.Unit == domain.UnitDay {
remaining = fmt.Sprintf("%d 天", e.RemainingDays)
} else {
remaining = fmt.Sprintf("%d 次", e.RemainingCount)
}
out.Rows = append(out.Rows, []string{ out.Rows = append(out.Rows, []string{
fmt.Sprint(s.ID), s.CustomerTail, s.CustomerName, s.PackageName, fmt.Sprint(s.ID), s.CustomerTail, s.CustomerName, s.PackageName,
map[string]string{domain.UnitDay: "按日", domain.UnitCount: "按次"}[s.Unit], "按日",
stateLabel[e.State], s.StartDate, s.EndDate, stateLabel[e.State], s.StartDate, s.EndDate,
fmt.Sprint(s.TotalCount), fmt.Sprint(s.UsedCount), remaining, s.Status, s.Note, s.UpdatedAt, fmt.Sprintf("%d 天", e.RemainingDays), s.Status, s.Note, s.UpdatedAt,
}) })
} }
return out, nil return out, nil
+45 -65
View File
@@ -3,57 +3,52 @@ package store
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"strconv"
"strings" "strings"
"daili/internal/domain"
) )
// 套餐计费方式:lite 版只有「按日」一种,
// 但 packages.unit 列保留(写入固定值 "day"),以便老库平滑升级。
const unitDay = "day"
// Package 服务套餐。 // Package 服务套餐。
type Package struct { type Package struct {
ID int64 ID int64
Name string Name string
Unit string DefaultAmount int // 默认天数
DefaultAmount int
PriceCents int PriceCents int
HasPrice bool HasPrice bool
Color string Color string
Note string Note string
Archived bool Archived bool
SortOrder int SortOrder int
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页 // CalendarSeparate 勾选后在工作台日历中单独成页
CalendarSeparate bool CalendarSeparate bool
// Tag 套餐标签:相同标签的套餐在日历里合并显示 // Tag 套餐标签:相同标签的套餐在日历里合并显示
Tag string Tag string
// Season 包赛季模式:开始~结束之间任意一天可整段完成 CreatedAt string
Season bool UpdatedAt string
// DefaultValidityDays 按次套餐默认有效期(天),0 不限 SubCount int
DefaultValidityDays int
// 包赛季的固定区间(套餐级)
SeasonStart string
SeasonEnd string
CreatedAt string
UpdatedAt string
SubCount int
ActiveCount int ActiveCount int
} }
// UnitLabel 返回类型中文名。 // UnitLabel 返回类型中文名(lite 版只有按日)。
func (p *Package) UnitLabel() string { func (p *Package) UnitLabel() string { return "按日" }
if p.Season {
return "包赛季" // AmountText 返回「默认量」列的中文描述。
} func (p *Package) AmountText() string {
if p.Unit == domain.UnitCount { return strconv.Itoa(p.DefaultAmount) + " 天"
return "按次"
}
return "按日"
} }
const packageCols = `id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at`
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
var p Package var p Package
var unit string
var price sql.NullInt64 var price sql.NullInt64
var archived, separate, season, validity int var archived, separate int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, err := row.Scan(&p.ID, &p.Name, &unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &p.CreatedAt, &p.UpdatedAt) &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -62,8 +57,6 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
} }
p.Archived = archived == 1 p.Archived = archived == 1
p.CalendarSeparate = separate == 1 p.CalendarSeparate = separate == 1
p.Season = season == 1
p.DefaultValidityDays = validity
if price.Valid { if price.Valid {
p.PriceCents = int(price.Int64) p.PriceCents = int(price.Int64)
p.HasPrice = true p.HasPrice = true
@@ -73,7 +66,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
// ListPackages 列出套餐(含订阅计数)。 // ListPackages 列出套餐(含订阅计数)。
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,p.season,p.default_validity_days,p.season_start,p.season_end,p.created_at,p.updated_at, q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,p.created_at,p.updated_at,
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id), (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed') (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
FROM packages p` FROM packages p`
@@ -89,16 +82,15 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
out := []Package{} out := []Package{}
for rows.Next() { for rows.Next() {
var p Package var p Package
var unit string
var price sql.NullInt64 var price sql.NullInt64
var archived, separate, season, validity int var archived, separate int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, if err := rows.Scan(&p.ID, &p.Name, &unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil { &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err return nil, err
} }
p.Archived = archived == 1 p.Archived = archived == 1
p.CalendarSeparate = separate == 1 p.CalendarSeparate = separate == 1
p.Season = season == 1
p.DefaultValidityDays = validity
if price.Valid { if price.Valid {
p.PriceCents = int(price.Int64) p.PriceCents = int(price.Int64)
p.HasPrice = true p.HasPrice = true
@@ -110,27 +102,29 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
// Package 取单个套餐。 // Package 取单个套餐。
func (db *DB) Package(id int64) (*Package, error) { func (db *DB) Package(id int64) (*Package, error) {
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,season,default_validity_days,season_start,season_end,created_at,updated_at return scanPackage(db.sql.QueryRow(`SELECT `+packageCols+` FROM packages WHERE id=?`, id))
FROM packages WHERE id=?`, id)) }
func (p *Package) normalize() {
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#3B6EA5"
}
} }
// CreatePackage 新建套餐。 // CreatePackage 新建套餐。
func (db *DB) CreatePackage(p *Package) (int64, error) { func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp() now := nowStamp()
p.Unit = normalizeUnit(p.Unit) p.normalize()
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any var price any
if p.HasPrice { if p.HasPrice {
price = p.PriceCents price = p.PriceCents
} }
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,season,default_validity_days,season_start,season_end,created_at,updated_at) res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, now, now) p.Name, unitDay, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), now, now)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -139,19 +133,13 @@ func (db *DB) CreatePackage(p *Package) (int64, error) {
// UpdatePackage 更新套餐。 // UpdatePackage 更新套餐。
func (db *DB) UpdatePackage(p *Package) error { func (db *DB) UpdatePackage(p *Package) error {
p.Unit = normalizeUnit(p.Unit) p.normalize()
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any var price any
if p.HasPrice { if p.HasPrice {
price = p.PriceCents price = p.PriceCents
} }
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,season=?,default_validity_days=?,season_start=?,season_end=?,updated_at=? _, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, nowStamp(), p.ID) WHERE id=?`, p.Name, unitDay, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), nowStamp(), p.ID)
return err return err
} }
@@ -208,11 +196,3 @@ func (db *DB) PackageTags() ([]string, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
// SeasonExpired 判断包赛季套餐在 today 是否已结束。
func (p Package) SeasonExpired(today string) bool {
if !p.Season || p.SeasonEnd == "" {
return false
}
return today > p.SeasonEnd
}
-93
View File
@@ -6,21 +6,6 @@ import (
"daili/internal/domain" "daili/internal/domain"
) )
// Stats 大屏统计数据。
type Stats struct {
Customers int
ActiveSubs int
PausedSubs int
DueToday int
Due7 int
DueTodaySubs []Subscription
Heat map[string]int // 结束日 -> 该日到期数量
MonthDue int
Top []Subscription
TodayTasks int
TodayDone int
}
// Badges 顶栏常驻徽标。 // Badges 顶栏常驻徽标。
func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) { func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) {
t := domain.Date(today) t := domain.Date(today)
@@ -37,84 +22,6 @@ func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) {
return return
} }
// ScreenStats 汇总大屏所需数据。
func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
st := &Stats{Heat: map[string]int{}}
var err error
if st.Customers, err = db.CustomerCount(); err != nil {
return nil, err
}
if st.ActiveSubs, err = db.CountActiveSubs(domain.Date(today)); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status='paused'
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`).Scan(&st.PausedSubs); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`,
domain.Date(today)).Scan(&st.DueToday); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`,
domain.Date(today), domain.Date(domain.AddDays(today, 6))).Scan(&st.Due7); err != nil {
return nil, err
}
if st.DueTodaySubs, err = db.DueOn(domain.Date(today)); err != nil {
return nil, err
}
// 本月到期热力
ms := domain.MonthStart(today.Year(), today.Month())
me := domain.MonthEnd(today.Year(), today.Month())
rows, err := db.sql.Query(`SELECT end_date, COUNT(*) FROM subscriptions s
JOIN customers c ON c.id=s.customer_id
WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date BETWEEN ? AND ? GROUP BY s.end_date`,
domain.Date(ms), domain.Date(me))
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
st.Heat[d] = n
st.MonthDue += n
}
rows.Close()
// TOP:按日按剩余天数、按次按剩余次数,各自排序后交替取前 10
t := domain.Date(today)
daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.unit='day' AND s.end_date>=?
ORDER BY s.end_date ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.unit='count' AND s.used_count < s.total_count
AND (IFNULL(s.end_date,'')='' OR s.end_date>=?)
ORDER BY (s.total_count - s.used_count) ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
for i := 0; i < 10 && len(st.Top) < 10; i++ {
if i < len(daySubs) {
st.Top = append(st.Top, daySubs[i])
}
if len(st.Top) < 10 && i < len(cntSubs) {
st.Top = append(st.Top, cntSubs[i])
}
if i >= len(daySubs) && i >= len(cntSubs) {
break
}
}
_ = warnDays
return st, nil
}
// RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。 // RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。
func (db *DB) RecentDue(from string, days int) ([]Subscription, error) { func (db *DB) RecentDue(from string, days int) ([]Subscription, error) {
to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1)) to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1))
+196 -287
View File
@@ -2,10 +2,8 @@ package store
import ( import (
"database/sql" "database/sql"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -26,9 +24,9 @@ func newTestDB(t *testing.T) *DB {
return db return db
} }
func mustPackage(t *testing.T, db *DB, name, unit string, amount int) *Package { func mustPackage(t *testing.T, db *DB, name string, amount int) *Package {
t.Helper() t.Helper()
id, err := db.CreatePackage(&Package{Name: name, Unit: unit, DefaultAmount: amount, Color: "#2F5D50"}) id, err := db.CreatePackage(&Package{Name: name, DefaultAmount: amount, Color: "#2F5D50"})
if err != nil { if err != nil {
t.Fatalf("创建套餐失败: %v", err) t.Fatalf("创建套餐失败: %v", err)
} }
@@ -50,7 +48,7 @@ func mustCustomer(t *testing.T, db *DB, tail, name string) int64 {
func TestCreateSubAndDuplicate(t *testing.T) { func TestCreateSubAndDuplicate(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -63,8 +61,8 @@ func TestCreateSubAndDuplicate(t *testing.T) {
if sub.EndDate != domain.Date(domain.AddDays(today, 29)) { if sub.EndDate != domain.Date(domain.AddDays(today, 29)) {
t.Fatalf("结束日=%s", sub.EndDate) t.Fatalf("结束日=%s", sub.EndDate)
} }
if sub.Unit != domain.UnitDay || sub.Status != domain.StatusActive { if sub.Status != domain.StatusActive {
t.Fatalf("unit=%s status=%s", sub.Unit, sub.Status) t.Fatalf("status=%s", sub.Status)
} }
if _, err := db.CreateSub(CreateSubInput{ if _, err := db.CreateSub(CreateSubInput{
@@ -85,7 +83,7 @@ func TestCreateSubAndDuplicate(t *testing.T) {
func TestExtendDaysFromTodayWhenExpired(t *testing.T) { func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -129,7 +127,7 @@ func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
func TestExtendDaysFromOldEndWhenActive(t *testing.T) { func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -151,7 +149,7 @@ func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
func TestExtendDaysRejectsBackwards(t *testing.T) { func TestExtendDaysRejectsBackwards(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -163,84 +161,9 @@ func TestExtendDaysRejectsBackwards(t *testing.T) {
} }
} }
func TestAddCountAndCheckinConsumption(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
cid := mustCustomer(t, db, "5678", "大鹏")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), TotalCount: 10,
})
if err != nil {
t.Fatal(err)
}
if sub.TotalCount != 10 || sub.UsedCount != 0 {
t.Fatalf("total=%d used=%d", sub.TotalCount, sub.UsedCount)
}
// 打卡完成 → 扣 1 次
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 同一天改成「跳过」→ 返还
if err := db.SaveCheckin(sub.ID, domain.Date(today), "skipped", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 再改回完成 → 再扣
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 撤销打卡 → 返还
ci, err := db.CheckinFor(sub.ID, domain.Date(today))
if err != nil {
t.Fatal(err)
}
if _, err := db.DeleteCheckin(ci.ID, 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 次数用尽后不能再打卡
_, _ = db.sql.Exec(`UPDATE subscriptions SET used_count=total_count WHERE id=?`, sub.ID)
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err == nil {
t.Fatal("次数用尽时打卡应被拒绝")
}
// 追加次数
if err := db.AddCount(sub.ID, 5, "", "加购", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.TotalCount != 15 {
t.Fatalf("total=%d want 15", got.TotalCount)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err != nil {
t.Fatalf("追加后应可打卡: %v", err)
}
}
func TestPauseResumeShiftsEndDate(t *testing.T) { func TestPauseResumeShiftsEndDate(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
@@ -276,35 +199,9 @@ func TestPauseResumeShiftsEndDate(t *testing.T) {
} }
} }
func TestCalendarSubsExcludesOpenEndedCount(t *testing.T) {
db := newTestDB(t)
dayPkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cntPkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
today := domain.Today()
cid := mustCustomer(t, db, "1234", "老张")
daySub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: dayPkg.ID, StartDate: domain.Date(today), Days: 30,
})
cid2 := mustCustomer(t, db, "5678", "大鹏")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: cntPkg.ID, StartDate: domain.Date(today), TotalCount: 10,
}); err != nil {
t.Fatal(err)
}
subs, err := db.CalendarSubs(domain.Date(today), domain.Date(today))
if err != nil {
t.Fatal(err)
}
if len(subs) != 1 || subs[0].ID != daySub.ID {
t.Fatalf("无有效期的按次订阅不应出现在日历: %+v", subs)
}
}
func TestBackup(t *testing.T) { func TestBackup(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30) mustPackage(t, db, "包月日常", 30)
path, err := db.BackupNow(14) path, err := db.BackupNow(14)
if err != nil { if err != nil {
t.Fatalf("备份失败: %v", err) t.Fatalf("备份失败: %v", err)
@@ -337,7 +234,7 @@ func TestSettingsAndBadges(t *testing.T) {
t.Fatalf("默认值=%s", got) t.Fatalf("默认值=%s", got)
} }
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today() today := domain.Today()
_, _ = db.CreateSub(CreateSubInput{ _, _ = db.CreateSub(CreateSubInput{
@@ -384,33 +281,9 @@ func TestUniqueCustomer(t *testing.T) {
var _ = time.Now var _ = time.Now
func TestScreenTopLimitedToTen(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
today := domain.Today()
for i := 0; i < 12; i++ {
cid, err := db.CreateCustomer(&Customer{Tail: "T" + strconv.Itoa(i), Name: "客户" + strconv.Itoa(i)})
if err != nil {
t.Fatalf("建客户失败: %v", err)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30 - i,
}); err != nil {
t.Fatalf("建订阅失败: %v", err)
}
}
st, err := db.ScreenStats(today, 3)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if len(st.Top) != 10 {
t.Fatalf("TOP 榜应为 10 条,实际 %d 条", len(st.Top))
}
}
func TestBackupKeepPrune(t *testing.T) { func TestBackupKeepPrune(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30) mustPackage(t, db, "包月日常", 30)
dir := db.BackupDir() dir := db.BackupDir()
names := []string{ names := []string{
"daili-20260101-000001.db", "daili-20260101-000001.db",
@@ -478,7 +351,7 @@ func TestSingleFileDatabase(t *testing.T) {
t.Fatalf("journal_mode=%s err=%v", mode, err) t.Fatalf("journal_mode=%s err=%v", mode, err)
} }
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
if _, err := db.CreateSub(CreateSubInput{ if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30, CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
@@ -513,7 +386,7 @@ func TestCopyDatabaseIsLossless(t *testing.T) {
if err := db.Migrate(); err != nil { if err := db.Migrate(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张") cid := mustCustomer(t, db, "1234", "老张")
sub, err := db.CreateSub(CreateSubInput{ sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30, CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
@@ -651,7 +524,7 @@ func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
func TestPackageCalendarSeparate(t *testing.T) { func TestPackageCalendarSeparate(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "每日任务", domain.UnitDay, 30) pkg := mustPackage(t, db, "每日任务", 30)
if pkg.CalendarSeparate { if pkg.CalendarSeparate {
t.Fatal("新建套餐默认不应勾选独立日历") t.Fatal("新建套餐默认不应勾选独立日历")
} }
@@ -678,7 +551,7 @@ func TestPackageCalendarSeparate(t *testing.T) {
func TestTaskUrgentFlags(t *testing.T) { func TestTaskUrgentFlags(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "4321", "老王") cid := mustCustomer(t, db, "4321", "老王")
sub1, err := db.CreateSub(CreateSubInput{ sub1, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30, CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
@@ -686,10 +559,9 @@ func TestTaskUrgentFlags(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pkg2 := mustPackage(t, db, "按次", domain.UnitCount, 10) pkg2 := mustPackage(t, db, "包周", 7)
sub2, err := db.CreateSub(CreateSubInput{ sub2, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()), CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()), Days: 7,
TotalCount: 10, EndDate: domain.Date(domain.AddDays(domain.Today(), 30)),
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -825,26 +697,17 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
if got, _ := db.Package(1); !got.CalendarSeparate { if got, _ := db.Package(1); !got.CalendarSeparate {
t.Fatal("迁移后新列不可写") t.Fatal("迁移后新列不可写")
} }
// 老库的 users / customers 也补上了新列 // 老客户仍可读
var userColor string if _, err := db.Customer(1); err != nil {
if err := db.sql.QueryRow(`SELECT IFNULL(color,'') FROM users WHERE id=1`).Scan(&userColor); err != nil {
t.Fatalf("users.color 未补上: %v", err)
}
if err := db.SetUserColor(1, "#123456"); err != nil {
t.Fatalf("老库 users.color 不可写: %v", err)
}
c, err := db.Customer(1)
if err != nil {
t.Fatalf("老客户读取失败: %v", err) t.Fatalf("老客户读取失败: %v", err)
} }
if c.AssigneeID != 0 { // 老套餐已被规范化成按日
t.Fatalf("老客户不应有打手: %+v", c) if pkg.UnitLabel() != "按日" {
t.Fatalf("老套餐未规范化为按日: %s", pkg.UnitLabel())
} }
if err := db.SetCustomerAssignee(1, 1); err != nil { // 迁移后仍可新建套餐(老库 packages.unit 是 NOT NULL 且没有默认值)
t.Fatalf("老库 customers.assignee_id 不可写: %v", err) if _, err := db.CreatePackage(&Package{Name: "新套餐", DefaultAmount: 7, Color: "#123456"}); err != nil {
} t.Fatalf("迁移后无法新建套餐: %v", err)
if c2, _ := db.Customer(1); c2.AssigneeID != 1 {
t.Fatalf("指派未生效: %+v", c2)
} }
// 新表 task_flags 已建立 // 新表 task_flags 已建立
@@ -858,171 +721,89 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
} }
} }
func TestUserColorAndAssignee(t *testing.T) { // TestSingleUserAccount 覆盖 lite 版只保留一个账户后的账户与会话行为。
func TestSingleUserAccount(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
// 新建管理员自动分配调色板颜色 u, err := db.CreateUser("admin", "老板", "daili123")
u1, err := db.CreateUser("dashou1", "阿强", "daili123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
u2, err := db.CreateUser("dashou2", "小美", "daili123") if u.ID != 1 {
if err != nil { t.Fatalf("首个账户 id 应为 1,实际 %d", u.ID)
t.Fatal(err)
} }
if u1.Color == "" || u2.Color == "" || u1.Color == u2.Color { if n, _ := db.CountUsers(); n != 1 {
t.Fatalf("自动颜色不合理: %q %q", u1.Color, u2.Color) t.Fatalf("账户数=%d want 1", n)
}
// 用户名唯一:不会再出现第二个账户
if _, err := db.CreateUser("admin", "另一个", "daili123"); err == nil {
t.Fatal("重复用户名应被拒绝")
} }
// 改色(小写转大写) // 登录
if err := db.SetUserColor(u2.ID, "#c0392b"); err != nil { if _, err := db.Authenticate("admin", "daili123"); err != nil {
t.Fatal(err) t.Fatalf("正确密码应可登录: %v", err)
} }
got, err := db.UserByID(u2.ID) if _, err := db.Authenticate("admin", "wrong"); err == nil {
if err != nil || got.Color != "#C0392B" { t.Fatal("错误密码不应通过")
t.Fatalf("颜色未保存或未规范化: %+v err=%v", got, err)
}
// 非法颜色
if err := db.SetUserColor(u2.ID, "red"); err == nil {
t.Fatal("非法颜色应报错")
}
if err := db.SetUserColor(u2.ID, "#12345"); err == nil {
t.Fatal("长度不对应报错")
}
// 不存在的账户
if err := db.SetUserColor(9999, "#123456"); err == nil {
t.Fatal("不存在的账户应报错")
} }
// 客户指派 // 改密码后旧密码失效
cid := mustCustomer(t, db, "3001", "客户甲") if err := db.UpdatePassword(u.ID, "newpass123"); err != nil {
if err := db.SetCustomerAssignee(cid, u1.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
c, err := db.Customer(cid) if _, err := db.Authenticate("admin", "daili123"); err == nil {
if err != nil { t.Fatal("旧密码不应还能登录")
t.Fatal(err)
} }
if c.AssigneeID != u1.ID || c.AssigneeName != "阿强" || c.AssigneeColor != "#2F5D50" { if _, err := db.Authenticate("admin", "newpass123"); err != nil {
t.Fatalf("客户指派信息不对: %+v", c) t.Fatalf("新密码应可登录: %v", err)
}
// 取消指派
if err := db.SetCustomerAssignee(cid, 0); err != nil {
t.Fatal(err)
}
if c2, _ := db.Customer(cid); c2.AssigneeID != 0 || c2.AssigneeName != "" {
t.Fatalf("取消指派失败: %+v", c2)
}
// 指派给不存在的打手
if err := db.SetCustomerAssignee(cid, 9999); err == nil {
t.Fatal("不存在的打手应报错")
} }
// 客户创建时直接带指派 // 改显示名称
cid2, err := db.CreateCustomer(&Customer{Tail: "3002", Name: "客户乙", AssigneeID: u2.ID}) if err := db.UpdateDisplayName(u.ID, " 强哥 "); err != nil {
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
c3, _ := db.Customer(cid2) got, err := db.UserByID(u.ID)
if c3.AssigneeName != "小美" || c3.AssigneeColor != "#C0392B" { if err != nil || got.DisplayName != "强哥" {
t.Fatalf("新建客户的指派未生效: %+v", c3) t.Fatalf("显示名称未更新: %+v err=%v", got, err)
}
// 订阅联表能带出打手
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
gotSub, err := db.Sub(sub.ID)
if err != nil {
t.Fatal(err)
}
if gotSub.AssigneeName != "小美" || gotSub.AssigneeColor != "#C0392B" {
t.Fatalf("订阅未带出打手: %+v", gotSub)
}
// 只有启用中的账户可作为打手候选
if err := db.SetUserActive(u1.ID, false); err != nil {
t.Fatal(err)
}
users, err := db.ActiveUsers()
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.ID == u1.ID {
t.Fatal("停用账户不应出现在打手候选里")
}
} }
} }
func TestUpdateUserByAdmin(t *testing.T) { // TestSessionLifecycle 覆盖会话创建、过期清理与登出。
func TestSessionLifecycle(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
u, err := db.CreateUser("admin", "老板", "daili123")
admin, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
u, err := db.CreateUser("dashou", "阿强", "dashou123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 建一个会话,稍后用密码重置把它踢掉 token := "tok-test"
token := "tok-" + domain.Date(domain.Today())
if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil { if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := db.SessionUser(token); err != nil { if _, err := db.SessionUser(token); err != nil {
t.Fatalf("会话应有效: %v", err) t.Fatalf("会话应有效: %v", err)
} }
// 过期会话不可用,且会被删除
// 只改昵称:会话保留 if err := db.CreateSession("tok-old", u.ID, -time.Hour, "127.0.0.1"); err != nil {
if err := db.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, _ := db.UserByID(u.ID) if _, err := db.SessionUser("tok-old"); err == nil {
if got.DisplayName != "强哥" || got.Username != "dashou" { t.Fatal("过期会话不应有效")
t.Fatalf("昵称未更新: %+v", got)
} }
if _, err := db.SessionUser(token); err != nil { // 登出
t.Fatalf("未改密码不应踢下线: %v", err) if err := db.DeleteSession(token); err != nil {
}
// 改用户名 + 重置密码:会话失效,新密码可登录
if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := db.SessionUser(token); err == nil { if _, err := db.SessionUser(token); err == nil {
t.Fatal("改密后旧会话应失效") t.Fatal("登出后会话应失效")
}
if _, err := db.Authenticate("laoban", "newpass123"); err != nil {
t.Fatalf("新密码应可登录: %v", err)
}
if _, err := db.Authenticate("laoban", "dashou123"); err == nil {
t.Fatal("旧密码不应还能登录")
}
// 用户名冲突
if err := db.UpdateUserByAdmin(u.ID, "admin", "", ""); !errors.Is(err, ErrDuplicate) {
t.Fatalf("重名应返回 ErrDuplicate, got %v", err)
}
// 空用户名
if err := db.UpdateUserByAdmin(u.ID, " ", "", ""); err == nil {
t.Fatal("空用户名应报错")
}
// 超管自己也在表里,但接口层才限制“只能改别人”
if admin.ID != 1 {
t.Fatalf("首个账户 id 应为 1,实际 %d", admin.ID)
} }
db.PurgeSessions()
} }
func TestCustomerArchiveFilters(t *testing.T) { func TestCustomerArchiveFilters(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) pkg := mustPackage(t, db, "包月日常", 30)
// 一个正常客户 + 一个按次客户 // 一个正常客户 + 一个按次客户
cid := mustCustomer(t, db, "7001", "小张") cid := mustCustomer(t, db, "7001", "小张")
@@ -1107,10 +888,138 @@ func TestCustomerArchiveFilters(t *testing.T) {
} }
} }
// TestNormalizeLegacyTypeData 验证 main 分支遗留的「按次 / 通行证 / 深渊」
// 数据在 lite 版迁移后统一折算成按日。
func TestNormalizeLegacyTypeData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.db")
old, err := sql.Open("sqlite", strings.ReplaceAll(path, "\\", "/"))
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE packages (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
default_amount INTEGER NOT NULL DEFAULT 30, price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#3B6EA5', note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
calendar_separate INTEGER NOT NULL DEFAULT 0, tag TEXT NOT NULL DEFAULT '',
season INTEGER NOT NULL DEFAULT 0, default_validity_days INTEGER NOT NULL DEFAULT 0,
season_start TEXT NOT NULL DEFAULT '', season_end TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0, cycle_days INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT, tail TEXT NOT NULL, name TEXT NOT NULL,
game_name TEXT NOT NULL DEFAULT '', game_server TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (tail, name))`,
`CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, package_id INTEGER NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
start_date TEXT NOT NULL, end_date TEXT,
total_count INTEGER NOT NULL DEFAULT 0, used_count INTEGER NOT NULL DEFAULT 0,
season INTEGER NOT NULL DEFAULT 0, season_done TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0, cycle_days INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '', created_by INTEGER,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT,
UNIQUE (customer_id, package_id))`,
// 三个套餐:按次 / 通行证 / 深渊
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('十次副本','count',10,'#111111',0,'','',0,0,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('通行证','day',1,'#222222',1,'2026-01-01','2026-01-31',0,0,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('深渊','day',4,'#333333',0,'','',1,7,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1001','甲','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1002','乙','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1003','丙','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 按次:10 次、已用 4 次、没有结束日 → 应折算成 2026-01-01 起共 10 天
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,created_at,updated_at)
VALUES(1,1,'count','active','2026-01-01',NULL,10,4,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 通行证:区间完整,清标记即可
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,season,created_at,updated_at)
VALUES(2,2,'day','active','2026-01-01','2026-01-31',1,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 深渊:周期 7 天 × 4 周期
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,abyss,cycle_days,total_count,created_at,updated_at)
VALUES(3,3,'day','active','2026-02-01','2026-02-28',1,7,4,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
}
for _, s := range stmts {
if _, err := old.Exec(s); err != nil {
t.Fatalf("准备老库失败: %v\nSQL: %s", err, s)
}
}
if err := old.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开老库失败: %v", err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
// 套餐:全部按日,season / abyss / cycle_days 归零
pkgs, err := db.ListPackages(true)
if err != nil {
t.Fatal(err)
}
if len(pkgs) != 3 {
t.Fatalf("套餐数量=%d want 3", len(pkgs))
}
for _, p := range pkgs {
if p.UnitLabel() != "按日" {
t.Fatalf("套餐 %q 未折算成按日: %s", p.Name, p.UnitLabel())
}
}
var legacy int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM packages WHERE unit<>'day' OR season<>0 OR abyss<>0 OR cycle_days<>0`).Scan(&legacy); err != nil {
t.Fatal(err)
}
if legacy != 0 {
t.Fatalf("仍有 %d 个套餐残留旧类型字段", legacy)
}
// 按次订阅:折算成 10 天,剩余 10 天
sub1, err := db.Sub(1)
if err != nil {
t.Fatal(err)
}
if sub1.StartDate != "2026-01-01" || sub1.EndDate != "2026-01-10" {
t.Fatalf("按次订阅折算区间不对: %s → %s", sub1.StartDate, sub1.EndDate)
}
// 通行证订阅:区间保留
sub2, err := db.Sub(2)
if err != nil {
t.Fatal(err)
}
if sub2.StartDate != "2026-01-01" || sub2.EndDate != "2026-01-31" {
t.Fatalf("通行证订阅区间被改动: %s → %s", sub2.StartDate, sub2.EndDate)
}
// 深渊订阅:区间保留
sub3, err := db.Sub(3)
if err != nil {
t.Fatal(err)
}
if sub3.StartDate != "2026-02-01" || sub3.EndDate != "2026-02-28" {
t.Fatalf("深渊订阅区间被改动: %s → %s", sub3.StartDate, sub3.EndDate)
}
// 迁移可重复执行
if err := db.Migrate(); err != nil {
t.Fatalf("重复迁移失败: %v", err)
}
}
func TestPackageTag(t *testing.T) { func TestPackageTag(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
p1 := mustPackage(t, db, "包月活动托", domain.UnitDay, 30) p1 := mustPackage(t, db, "包月活动托", 30)
if p1.Tag != "" { if p1.Tag != "" {
t.Fatalf("默认标签应为空: %q", p1.Tag) t.Fatalf("默认标签应为空: %q", p1.Tag)
} }
@@ -1123,7 +1032,7 @@ func TestPackageTag(t *testing.T) {
t.Fatalf("标签未保存: %+v err=%v", got, err) t.Fatalf("标签未保存: %+v err=%v", got, err)
} }
p2 := &Package{Name: "深渊委托", Unit: domain.UnitCount, DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "} p2 := &Package{Name: "深渊委托", DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "}
if _, err := db.CreatePackage(p2); err != nil { if _, err := db.CreatePackage(p2); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+40 -176
View File
@@ -10,25 +10,19 @@ import (
"daili/internal/domain" "daili/internal/domain"
) )
// Subscription 服务订阅。 // Subscription 服务订阅(lite 版只有按日:一定有开始日与结束日)。
type Subscription struct { type Subscription struct {
ID int64 ID int64
CustomerID int64 CustomerID int64
PackageID int64 PackageID int64
Unit string
Status string Status string
StartDate string StartDate string
EndDate string EndDate string
TotalCount int
UsedCount int
Note string Note string
CreatedBy int64 CreatedBy int64
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
ClosedAt string ClosedAt string
// Season 包赛季;SeasonDone 标记整段完成的日期(空=未完成)
Season bool
SeasonDone string
// 联表 // 联表
PackageName string PackageName string
@@ -36,21 +30,13 @@ type Subscription struct {
CustomerTail string CustomerTail string
CustomerName string CustomerName string
CustomerContact string CustomerContact string
// 客户归属的打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
} }
// Eval 计算展示状态。 // Eval 计算展示状态。
func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval { func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
return domain.EvalSub(s.Unit, s.Status, s.StartDate, s.EndDate, s.TotalCount, s.UsedCount, today, warnDays) return domain.EvalSub(s.Status, s.StartDate, s.EndDate, today, warnDays)
} }
// IsCount 是否按次套餐。
func (s *Subscription) IsCount() bool { return s.Unit == domain.UnitCount }
// Pause 暂停分段。 // Pause 暂停分段。
type Pause struct { type Pause struct {
ID int64 ID int64
@@ -70,9 +56,6 @@ type Adjustment struct {
OldEndDate string OldEndDate string
NewEndDate string NewEndDate string
DaysAdded int DaysAdded int
CountAdded int
OldTotal int
NewTotal int
Note string Note string
OperatorID int64 OperatorID int64
OperatorName string OperatorName string
@@ -91,40 +74,30 @@ func (a *Adjustment) KindLabel() string {
return "开通" return "开通"
case "extend_days": case "extend_days":
return "追加天数" return "追加天数"
case "add_count":
return "追加次数"
case "set_end_date": case "set_end_date":
return "调整结束日" return "调整结束日"
} }
return "调整" return "调整"
} }
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''), const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.status,s.start_date,IFNULL(s.end_date,''),
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''), s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
IFNULL(s.season,0),IFNULL(s.season_done,''), p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
p.name,p.color,c.tail,c.name,IFNULL(c.contact,''),
IFNULL(c.assignee_id,0),
IFNULL((SELECT u.display_name FROM users u WHERE u.id=c.assignee_id),''),
IFNULL((SELECT u.color FROM users u WHERE u.id=c.assignee_id),'')
FROM subscriptions s FROM subscriptions s
JOIN packages p ON p.id=s.package_id JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_id` JOIN customers c ON c.id=s.customer_id`
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) { func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
var s Subscription var s Subscription
var seasonInt int err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Status, &s.StartDate, &s.EndDate,
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt, &s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact)
&seasonInt, &s.SeasonDone,
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact,
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return nil, err return nil, err
} }
s.Season = seasonInt == 1
return &s, nil return &s, nil
} }
@@ -185,7 +158,7 @@ type SubFilter struct {
Today time.Time Today time.Time
} }
const expiredExpr = `((s.unit='day' AND s.end_date < ?) OR (s.unit='count' AND (s.used_count>=s.total_count OR (IFNULL(s.end_date,'')<>'' AND s.end_date < ?))))` const expiredExpr = `(IFNULL(s.end_date,'')<>'' AND s.end_date < ?)`
// ListSubs 分页查询订阅。 // ListSubs 分页查询订阅。
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) { func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
@@ -204,14 +177,14 @@ func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
switch f.State { switch f.State {
case "active": case "active":
where = append(where, "s.status='active'", "NOT "+expiredExpr) where = append(where, "s.status='active'", "NOT "+expiredExpr)
args = append(args, today, today) args = append(args, today)
case "paused": case "paused":
where = append(where, "s.status='paused'") where = append(where, "s.status='paused'")
case "closed": case "closed":
where = append(where, "s.status='closed'") where = append(where, "s.status='closed'")
case "expired": case "expired":
where = append(where, "s.status<>'closed'", expiredExpr) where = append(where, "s.status<>'closed'", expiredExpr)
args = append(args, today, today) args = append(args, today)
case "due7": case "due7":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?") where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 6))) args = append(args, today, domain.Date(domain.AddDays(f.Today, 6)))
@@ -256,7 +229,6 @@ type CreateSubInput struct {
StartDate string StartDate string
EndDate string EndDate string
Days int Days int
TotalCount int
Note string Note string
OperatorID int64 OperatorID int64
} }
@@ -270,76 +242,31 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
if pkg.Archived { if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅") return nil, errors.New("该套餐已归档,不能新建订阅")
} }
if pkg.Season && strings.TrimSpace(in.StartDate) == "" {
in.StartDate = pkg.SeasonStart
}
start, err := domain.ParseDate(in.StartDate) start, err := domain.ParseDate(in.StartDate)
if err != nil { if err != nil {
return nil, errors.New("开始日期格式不正确") return nil, errors.New("开始日期格式不正确")
} }
unit := pkg.Unit
endDate := "" days := in.Days
days := 0 if days <= 0 {
total := 0 days = pkg.DefaultAmount
if unit == domain.UnitDay {
days = in.Days
if days <= 0 {
days = pkg.DefaultAmount
}
if days <= 0 {
days = 30
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("结束日期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("结束日期不能早于开始日期")
}
endDate = domain.Date(end)
days = domain.DayDiff(start, end) + 1
} else {
endDate = domain.Date(domain.AddDays(start, days-1))
}
} else {
total = in.TotalCount
if total <= 0 {
total = pkg.DefaultAmount
}
if total <= 0 {
return nil, errors.New("总次数必须大于 0")
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("有效期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
} else if pkg.DefaultValidityDays > 0 {
endDate = domain.Date(domain.AddDays(start, pkg.DefaultValidityDays-1))
}
} }
if pkg.Season { if days <= 0 {
if pkg.SeasonStart == "" || pkg.SeasonEnd == "" { days = 30
return nil, errors.New("该赛季套餐未设置起止日期") }
} endDate := ""
end, err := domain.ParseDate(pkg.SeasonEnd) if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil { if err != nil {
return nil, errors.New("赛季结束日期不正确") return nil, errors.New("结束日期格式不正确")
}
if domain.DayDiff(domain.Today(), end) < 0 {
return nil, errors.New("该赛季已结束,不能开通")
} }
if domain.DayDiff(start, end) < 0 { if domain.DayDiff(start, end) < 0 {
return nil, errors.New("赛季结束日期早于开始日期") return nil, errors.New("结束日期不能早于开始日期")
} }
endDate = domain.Date(end) endDate = domain.Date(end)
unit = domain.UnitDay
days = domain.DayDiff(start, end) + 1 days = domain.DayDiff(start, end) + 1
} else {
endDate = domain.Date(domain.AddDays(start, days-1))
} }
now := nowStamp() now := nowStamp()
@@ -354,9 +281,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
return err return err
} }
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at,season) res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,note,created_by,created_at,updated_at)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?)`, VALUES(?,?,?,'active',?,?,?,?,?,?)`,
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now, b2i(pkg.Season)) in.CustomerID, in.PackageID, unitDay, domain.Date(start), nullIfEmpty(endDate), in.Note, nullIfZero(in.OperatorID), now, now)
if err != nil { if err != nil {
if isUnique(err) { if isUnique(err) {
return errors.New("该客户已订阅此套餐,请改用「追加时长」") return errors.New("该客户已订阅此套餐,请改用「追加时长」")
@@ -364,9 +291,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
return err return err
} }
id, _ = res.LastInsertId() id, _ = res.LastInsertId()
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at) _, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?)`,
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, 0, 0, total, "新建订阅", nullIfZero(in.OperatorID), now) id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, "新建订阅", nullIfZero(in.OperatorID), now)
return err return err
}) })
if err != nil { if err != nil {
@@ -395,9 +322,6 @@ func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64)
if err != nil { if err != nil {
return err return err
} }
if s.Unit != domain.UnitDay {
return errors.New("该订阅按次计费,请使用「追加次数」")
}
if s.Status == domain.StatusClosed { if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」") return errors.New("订阅已结束,请先「重新启用」")
} }
@@ -414,58 +338,14 @@ func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64)
domain.Date(newEnd), status, nowStamp(), subID); err != nil { domain.Date(newEnd), status, nowStamp(), subID); err != nil {
return err return err
} }
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at) _, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?)`,
subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp()) subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp())
return err return err
}) })
return plan, err return plan, err
} }
// AddCount 按次追加次数(可同时调整有效期)。
func (db *DB) AddCount(subID int64, add int, newEndDate, note string, operatorID int64) error {
if add <= 0 {
return errors.New("追加次数必须大于 0")
}
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitCount {
return errors.New("该订阅按日计费,请使用「追加天数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
endDate := s.EndDate
if newEndDate != "" {
end, err := domain.ParseDate(newEndDate)
if err != nil {
return errors.New("有效期格式不正确")
}
if end.Before(domain.ParseDateOr(s.StartDate, domain.Today())) {
return errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
newTotal := s.TotalCount + add
if _, err := tx.Exec(`UPDATE subscriptions SET total_count=?, end_date=?, status=?, updated_at=? WHERE id=?`,
newTotal, nullIfEmpty(endDate), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,0,?,?,?,?,?,?)`,
subID, "add_count", s.EndDate, endDate, add, s.TotalCount, newTotal, note, nullIfZero(operatorID), nowStamp())
return err
})
return err
}
// PauseSub 暂停订阅。 // PauseSub 暂停订阅。
func (db *DB) PauseSub(subID, operatorID int64) error { func (db *DB) PauseSub(subID, operatorID int64) error {
return db.withTx(func(tx *sql.Tx) error { return db.withTx(func(tx *sql.Tx) error {
@@ -488,7 +368,7 @@ func (db *DB) PauseSub(subID, operatorID int64) error {
}) })
} }
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。 // ResumeSub 恢复订阅,顺延结束日,返回顺延天数。
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) { func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
var shift int var shift int
today := domain.Today() today := domain.Today()
@@ -516,7 +396,7 @@ func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil { if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
return err return err
} }
if s.Unit == domain.UnitDay && shift > 0 { if shift > 0 {
oldEnd := domain.ParseDateOr(s.EndDate, today) oldEnd := domain.ParseDateOr(s.EndDate, today)
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`, if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil { domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
@@ -546,7 +426,7 @@ func (db *DB) ReopenSub(subID int64) error {
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。 // AdjustmentsBySub 取某订阅的追加记录(新→旧)。
func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) { func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''), return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at, a.days_added,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name c.tail,c.name,p.name
FROM sub_adjustments a FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id LEFT JOIN users u ON u.id=a.operator_id
@@ -559,7 +439,7 @@ func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
// RecentAdjustments 最近追加记录。 // RecentAdjustments 最近追加记录。
func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) { func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''), return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at, a.days_added,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name c.tail,c.name,p.name
FROM sub_adjustments a FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id LEFT JOIN users u ON u.id=a.operator_id
@@ -579,7 +459,7 @@ func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
for rows.Next() { for rows.Next() {
var a Adjustment var a Adjustment
if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate, if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate,
&a.DaysAdded, &a.CountAdded, &a.OldTotal, &a.NewTotal, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt, &a.DaysAdded, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil { &a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
return nil, err return nil, err
} }
@@ -624,10 +504,8 @@ func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
func (db *DB) CountActiveSubs(today string) (int, error) { func (db *DB) CountActiveSubs(today string) (int, error) {
var n int var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active'
AND IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0 AND ( AND IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0
(s.unit='day' AND s.end_date >= ?) OR AND s.end_date >= ?`, today).Scan(&n)
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
)`, today, today).Scan(&n)
return n, err return n, err
} }
@@ -636,20 +514,6 @@ func (s *Subscription) SubTitle() string {
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName) return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
} }
// SeasonComplete 把包赛季订阅整段标记为已完成。
func (db *DB) SeasonComplete(subID int64, date string) error {
sub, err := db.Sub(subID)
if err != nil {
return err
}
if !sub.Season {
return errors.New("不是包赛季订阅")
}
_, err = db.sql.Exec(`UPDATE subscriptions SET season_done=?, updated_at=? WHERE id=?`,
date, nowStamp(), subID)
return err
}
// DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。 // DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。
func (db *DB) DeleteSubscription(subID int64) error { func (db *DB) DeleteSubscription(subID int64) error {
res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID) res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID)
+12 -160
View File
@@ -10,46 +10,25 @@ import (
) )
// User 管理员账户。 // User 管理员账户。
//
// lite 版只保留首次启动时创建的那一个账户:不再有新增/停用/改他人资料,
// 因此这里只保留登录、改自己密码与会话相关的最小集合。
type User struct { type User struct {
ID int64 ID int64
Username string Username string
DisplayName string DisplayName string
IsActive bool IsActive bool
Color string
CreatedAt string CreatedAt string
LastLoginAt string LastLoginAt string
} }
// AssigneePalette 是新建管理员时自动分配的颜色(区分度较高的深色系)。
var AssigneePalette = []string{
"#2F5D50", "#8C4A3C", "#3E5C8A", "#7A5AA6",
"#A66A1E", "#2C7A7B", "#8A3B5C", "#5B6B2F",
}
// NormalizeColor 校验并规范化 #RRGGBB;空值返回空。
func NormalizeColor(c string) (string, error) {
c = strings.TrimSpace(c)
if c == "" {
return "", nil
}
if len(c) != 7 || c[0] != '#' {
return "", errors.New("颜色格式应为 #RRGGBB")
}
for _, r := range c[1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return "", errors.New("颜色格式应为 #RRGGBB")
}
}
return strings.ToUpper(c), nil
}
func scanUser(row interface { func scanUser(row interface {
Scan(dest ...any) error Scan(dest ...any) error
}) (*User, error) { }) (*User, error) {
var u User var u User
var active int var active int
var last sql.NullString var last sql.NullString
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last, &u.Color); err != nil { if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -60,7 +39,7 @@ func scanUser(row interface {
return &u, nil return &u, nil
} }
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,''), IFNULL(color,'')` const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
// CountUsers 返回账户数量。 // CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) { func (db *DB) CountUsers() (int, error) {
@@ -69,16 +48,15 @@ func (db *DB) CountUsers() (int, error) {
return n, err return n, err
} }
// CreateUser 新建账户。 // CreateUser 新建账户。只用于首次初始化,之后不再允许新增。
func (db *DB) CreateUser(username, displayName, password string) (*User, error) { func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
hash, err := pw.Hash(password) hash, err := pw.Hash(password)
if err != nil { if err != nil {
return nil, err return nil, err
} }
now := nowStamp() now := nowStamp()
color := db.nextAssigneeColor() res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, color, created_at) VALUES(?,?,?,1,?)`, username, displayName, hash, now)
VALUES(?,?,?,1,?,?)`, username, displayName, hash, color, now)
if err != nil { if err != nil {
if isUnique(err) { if isUnique(err) {
return nil, ErrDuplicate return nil, ErrDuplicate
@@ -86,106 +64,7 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err return nil, err
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, Color: color, CreatedAt: now}, nil return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
}
// nextAssigneeColor 依据现有账户数循环取调色板,尽量不重复。
func (db *DB) nextAssigneeColor() string {
n, _ := db.CountUsers()
return AssigneePalette[n%len(AssigneePalette)]
}
// SetUserColor 设置管理员颜色(空字符串表示清除)。
func (db *DB) SetUserColor(userID int64, color string) error {
c, err := NormalizeColor(color)
if err != nil {
return err
}
res, err := db.sql.Exec(`UPDATE users SET color=? WHERE id=?`, c, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetCustomerAssignee 把客户指派给某个打手;userID=0 表示取消指派。
func (db *DB) SetCustomerAssignee(customerID, userID int64) error {
var v any
if userID > 0 {
if _, err := db.UserByID(userID); err != nil {
return errors.New("打手不存在")
}
v = userID
}
res, err := db.sql.Exec(`UPDATE customers SET assignee_id=?, updated_at=? WHERE id=?`, v, nowStamp(), customerID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ActiveUsers 返回在用的管理员(打手候选)。
func (db *DB) ActiveUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users WHERE is_active=1 ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// DeleteUserSessions 清除某个账户的全部登录会话。
func (db *DB) DeleteUserSessions(userID int64) error {
_, err := db.sql.Exec(`DELETE FROM sessions WHERE user_id=?`, userID)
return err
}
// UpdateUserByAdmin 由超管修改用户名/昵称,password 非空时同时重置密码。
// 重置密码会强制该账户下线。
func (db *DB) UpdateUserByAdmin(userID int64, username, displayName, password string) error {
username = strings.TrimSpace(username)
displayName = strings.TrimSpace(displayName)
if username == "" {
return errors.New("用户名不能为空")
}
hash := ""
if password != "" {
var err error
hash, err = pw.Hash(password)
if err != nil {
return err
}
}
if _, err := db.sql.Exec(`UPDATE users SET username=?, display_name=? WHERE id=?`,
username, displayName, userID); err != nil {
if isUnique(err) {
return ErrDuplicate
}
return err
}
if hash != "" {
if _, err := db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID); err != nil {
return err
}
if err := db.DeleteUserSessions(userID); err != nil {
return err
}
}
return nil
} }
// Authenticate 校验用户名口令。 // Authenticate 校验用户名口令。
@@ -213,29 +92,6 @@ func (db *DB) UserByID(id int64) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id)) return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
} }
// UserByUsername 按用户名取账户。
func (db *DB) UserByUsername(name string) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE username=?`, name))
}
// ListUsers 列出账户。
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// UpdatePassword 修改口令。 // UpdatePassword 修改口令。
func (db *DB) UpdatePassword(userID int64, password string) error { func (db *DB) UpdatePassword(userID int64, password string) error {
hash, err := pw.Hash(password) hash, err := pw.Hash(password)
@@ -246,13 +102,9 @@ func (db *DB) UpdatePassword(userID int64, password string) error {
return err return err
} }
// SetUserActive 启用/停用账户。 // UpdateDisplayName 修改显示名称(仅限自己)。
func (db *DB) SetUserActive(userID int64, active bool) error { func (db *DB) UpdateDisplayName(userID int64, displayName string) error {
v := 0 _, err := db.sql.Exec(`UPDATE users SET display_name=? WHERE id=?`, strings.TrimSpace(displayName), userID)
if active {
v = 1
}
_, err := db.sql.Exec(`UPDATE users SET is_active=? WHERE id=?`, v, userID)
return err return err
} }
+23 -72
View File
@@ -26,17 +26,10 @@ type DayCell struct {
DoneCount int DoneCount int
UrgentCnt int UrgentCnt int
Heat int Heat int
// 赛季色带
HasSeason bool
SeasonColor string
SeasonStart bool
SeasonEnd bool
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount) // PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
PendingCount int PendingCount int
Tickets []Ticket Tickets []Ticket
More int More int
// Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰)
Strip []string
} }
// Ticket 是日历格里的色签。 // Ticket 是日历格里的色签。
@@ -50,8 +43,6 @@ type Ticket struct {
Skipped bool Skipped bool
IsDue bool IsDue bool
Urgent bool Urgent bool
// AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底)
AssigneeColor string
} }
// TaskRow 是当日待打清单的一行。 // TaskRow 是当日待打清单的一行。
@@ -117,12 +108,12 @@ type calendarData struct {
// calSub 是日历计算用的订阅上下文。 // calSub 是日历计算用的订阅上下文。
type calSub struct { type calSub struct {
sub store.Subscription sub store.Subscription
start time.Time start time.Time
end time.Time end time.Time
hasEnd bool hasEnd bool
eval domain.Eval pauses []store.Pause
pauses []store.Pause checkins map[string]store.Checkin
} }
func (c calSub) pausedOn(date string) bool { func (c calSub) pausedOn(date string) bool {
@@ -141,18 +132,9 @@ func (c calSub) covers(date string) bool {
if c.sub.Status == domain.StatusClosed || !c.hasEnd { if c.sub.Status == domain.StatusClosed || !c.hasEnd {
return false return false
} }
if c.sub.Season {
if c.sub.SeasonDone != "" {
return false
}
return date >= c.sub.StartDate && date <= c.sub.EndDate
}
if date < c.sub.StartDate || date > c.sub.EndDate { if date < c.sub.StartDate || date > c.sub.EndDate {
return false return false
} }
if c.sub.Unit == domain.UnitCount && c.eval.RemainingCount <= 0 {
return false
}
return !c.pausedOn(date) return !c.pausedOn(date)
} }
@@ -288,10 +270,10 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
list := make([]calSub, 0, len(subs)) list := make([]calSub, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
cs := calSub{ cs := calSub{
sub: sub, sub: sub,
start: domain.ParseDateOr(sub.StartDate, today), start: domain.ParseDateOr(sub.StartDate, today),
eval: sub.Eval(today, warn), pauses: pauses[sub.ID],
pauses: pauses[sub.ID], checkins: checkins[sub.ID],
} }
if sub.EndDate != "" { if sub.EndDate != "" {
cs.end = domain.ParseDateOr(sub.EndDate, today) cs.end = domain.ParseDateOr(sub.EndDate, today)
@@ -349,7 +331,10 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if cs.sub.EndDate == ds { if cs.sub.EndDate == ds {
cell.DueCount++ cell.DueCount++
} }
if !cell.InMonth || !cs.covers(ds) { if !cell.InMonth {
continue
}
if !cs.covers(ds) {
continue continue
} }
cell.TaskCount++ cell.TaskCount++
@@ -359,31 +344,20 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
cell.UrgentCnt++ cell.UrgentCnt++
} }
t := Ticket{ t := Ticket{
SubID: cs.sub.ID, SubID: cs.sub.ID,
Tail: cs.sub.CustomerTail, Tail: cs.sub.CustomerTail,
Name: cs.sub.CustomerName, Name: cs.sub.CustomerName,
Pkg: shortName(cs.sub.PackageName), Pkg: shortName(cs.sub.PackageName),
Color: cs.sub.PackageColor, Color: cs.sub.PackageColor,
Done: ci.Status == "done", Done: ci.Status == "done",
Skipped: ci.Status == "skipped", Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds, IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent, Urgent: isUrgent,
AssigneeColor: cs.sub.AssigneeColor,
} }
if t.Done || t.Skipped { if t.Done || t.Skipped {
cell.DoneCount++ cell.DoneCount++
} }
tickets = append(tickets, t) tickets = append(tickets, t)
if cs.sub.Season && cs.sub.SeasonDone == "" {
cell.HasSeason = true
cell.SeasonColor = cs.sub.PackageColor
if ds == cs.sub.StartDate {
cell.SeasonStart = true
}
if ds == cs.sub.EndDate {
cell.SeasonEnd = true
}
}
} }
sort.SliceStable(tickets, func(i, j int) bool { sort.SliceStable(tickets, func(i, j int) bool {
if tickets[i].Urgent != tickets[j].Urgent { if tickets[i].Urgent != tickets[j].Urgent {
@@ -394,8 +368,6 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
} }
return tickets[i].SubID < tickets[j].SubID return tickets[i].SubID < tickets[j].SubID
}) })
// 竖条按“当天全部任务”算,不能被色签的 3 条上限截断
cell.Strip = assigneeStrip(tickets)
if len(tickets) > 3 { if len(tickets) > 3 {
cell.More = len(tickets) - 3 cell.More = len(tickets) - 3
tickets = tickets[:3] tickets = tickets[:3]
@@ -529,27 +501,6 @@ func sortTasks(tasks []TaskRow) {
}) })
} }
// assigneeStrip 把一格里所有任务的打手颜色去重后返回,未指派用灰色占位。
func assigneeStrip(tickets []Ticket) []string {
if len(tickets) == 0 {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, 4)
for _, t := range tickets {
c := t.AssigneeColor
if c == "" {
c = "#8A8F98"
}
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
func heatLevel(n int) int { func heatLevel(n int) int {
switch { switch {
case n <= 0: case n <= 0:
+8 -48
View File
@@ -58,13 +58,11 @@ func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
type customerFormData struct { type customerFormData struct {
Customer *store.Customer Customer *store.Customer
IsNew bool IsNew bool
Users []store.User
} }
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers") p := s.newPage(r, "新建客户", "customers")
users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true, Users: users}
s.render(w, r, "customer_form", p) s.render(w, r, "customer_form", p)
} }
@@ -78,7 +76,6 @@ func (s *Server) readCustomerForm(r *http.Request) *store.Customer {
Tags: normalizeTags(r.PostFormValue("tags")), Tags: normalizeTags(r.PostFormValue("tags")),
Source: strings.TrimSpace(r.PostFormValue("source")), Source: strings.TrimSpace(r.PostFormValue("source")),
Note: strings.TrimSpace(r.PostFormValue("note")), Note: strings.TrimSpace(r.PostFormValue("note")),
AssigneeID: int64(s.formInt(r, "assignee_id", 0)),
} }
} }
@@ -117,8 +114,7 @@ func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
c := s.readCustomerForm(r) c := s.readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers") p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" { if msg := validateCustomer(c); msg != "" {
users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: c, IsNew: true}
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p) s.fail(w, r, "customer_form", msg, p)
return return
} }
@@ -128,8 +124,7 @@ func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, store.ErrDuplicate) { if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅" msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
} }
users, _ := s.db.ActiveUsers() p.D = &customerFormData{Customer: c, IsNew: true}
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p) s.fail(w, r, "customer_form", msg, p)
return return
} }
@@ -191,7 +186,6 @@ type customerDetailData struct {
AdjSubColor map[int64]string AdjSubColor map[int64]string
Checkins []CheckinRow Checkins []CheckinRow
Packages []store.Package Packages []store.Package
Users []store.User
CustCal *custCal CustCal *custCal
Timeline *Timeline Timeline *Timeline
Extend *ExtendPanel Extend *ExtendPanel
@@ -240,10 +234,10 @@ func (s *Server) buildCustomerCal(c *store.Customer, cm string, today time.Time)
} }
pauses, _ := s.db.PausesForSubs(ids) pauses, _ := s.db.PausesForSubs(ids)
checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1))) checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1)))
warn := s.warnDays()
list := make([]calSub, 0, len(subs)) list := make([]calSub, 0, len(subs))
for _, sub := range subs { for _, sub := range subs {
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)} cs := calSub{sub: sub, pauses: pauses[sub.ID], checkins: checkins[sub.ID]}
cs.start = domain.ParseDateOr(sub.StartDate, today)
if sub.EndDate != "" { if sub.EndDate != "" {
cs.hasEnd = true cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today) cs.end = domain.ParseDateOr(sub.EndDate, today)
@@ -322,7 +316,6 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
} }
pauses, _ := s.db.PausesForSubs(ids) pauses, _ := s.db.PausesForSubs(ids)
users, _ := s.db.ActiveUsers()
// 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加 // 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加
owned := map[int64]bool{} owned := map[int64]bool{}
for _, sub := range subs { for _, sub := range subs {
@@ -344,7 +337,6 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
return sorted[i].ID < sorted[j].ID return sorted[i].ID < sorted[j].ID
}) })
data := &customerDetailData{ data := &customerDetailData{
Users: users,
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today), CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
Customer: c, Customer: c,
Subs: subViews(sorted, today, warn), Subs: subViews(sorted, today, warn),
@@ -384,7 +376,7 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "customer_detail", p) s.render(w, r, "customer_detail", p)
} }
// handleCustomerArchive 停用客户:默认从列表、日历、大屏隐藏,订阅保留。 // handleCustomerArchive 停用客户:默认从列表与日历隐藏,订阅保留。
func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) {
id := pathID(r) id := pathID(r)
c, err := s.db.Customer(id) c, err := s.db.Customer(id)
@@ -436,31 +428,7 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name) s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name)
} }
// handleCustomerAssignee 从客户详情快速指派/取消打手。 // handleSubCreate 给客户开通一个按日套餐。
func (s *Server) handleCustomerAssignee(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
userID := int64(s.formInt(r, "assignee_id", 0))
if err := s.db.SetCustomerAssignee(id, userID); err != nil {
s.redirect(w, r, "/customers/"+itoa(id), "指派失败:"+err.Error())
return
}
c, _ := s.db.Customer(id)
word := "已取消指派"
if userID > 0 {
if u, err := s.db.UserByID(userID); err == nil {
name := u.DisplayName
if name == "" {
name = u.Username
}
word = "已指派给 " + name
}
}
if c != nil {
s.logOp(r, "指派打手", "customer", id, c.Tail+" "+c.Name+" "+word)
}
s.redirect(w, r, "/customers/"+itoa(id), word)
}
func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
customerID := pathID(r) customerID := pathID(r)
c, err := s.db.Customer(customerID) c, err := s.db.Customer(customerID)
@@ -474,7 +442,6 @@ func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
StartDate: r.PostFormValue("start_date"), StartDate: r.PostFormValue("start_date"),
EndDate: r.PostFormValue("end_date"), EndDate: r.PostFormValue("end_date"),
Days: s.formInt(r, "days", 0), Days: s.formInt(r, "days", 0),
TotalCount: s.formInt(r, "total_count", 0),
Note: strings.TrimSpace(r.PostFormValue("note")), Note: strings.TrimSpace(r.PostFormValue("note")),
} }
u := s.currentUser(r) u := s.currentUser(r)
@@ -487,17 +454,10 @@ func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
s.logOp(r, "新建订阅", "subscription", sub.ID, s.logOp(r, "新建订阅", "subscription", sub.ID,
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+unitLabel(sub.Unit)+")") "为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"(按日)")
s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」") s.redirect(w, r, "/customers/"+itoa(customerID), "已开通「"+sub.PackageName+"」")
} }
func unitLabel(u string) string {
if u == domain.UnitCount {
return "按次"
}
return "按日"
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) } func itoa(v int64) string { return strconv.FormatInt(v, 10) }
func sortAdjustments(list []store.Adjustment) { func sortAdjustments(list []store.Adjustment) {
+1 -183
View File
@@ -2,7 +2,6 @@ package web
import ( import (
"encoding/csv" "encoding/csv"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -47,15 +46,12 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
type settingsData struct { type settingsData struct {
WarnDays int WarnDays int
ScreenRefresh int
Users []store.User
Backups []string Backups []string
DBPath string DBPath string
BackupDir string BackupDir string
BackupKeep int BackupKeep int
BackupEnabled bool BackupEnabled bool
ConfigPath string ConfigPath string
ScreenPublic bool
Addrs []string Addrs []string
Port string Port string
Uptime string Uptime string
@@ -120,7 +116,6 @@ func LocalAddrs() []string {
} }
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
users, _ := s.db.ListUsers()
backups, _ := s.db.ListBackups() backups, _ := s.db.ListBackups()
custN, _ := s.db.CustomerCount() custN, _ := s.db.CustomerCount()
pkgs, _ := s.db.ListPackages(true) pkgs, _ := s.db.ListPackages(true)
@@ -131,15 +126,12 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
} }
data := &settingsData{ data := &settingsData{
WarnDays: s.warnDays(), WarnDays: s.warnDays(),
ScreenRefresh: s.screenRefresh(),
Users: users,
Backups: backups, Backups: backups,
DBPath: s.db.Path, DBPath: s.db.Path,
BackupDir: s.cfg.BackupDir, BackupDir: s.cfg.BackupDir,
BackupKeep: s.cfg.BackupKeep, BackupKeep: s.cfg.BackupKeep,
BackupEnabled: s.cfg.BackupEnabled, BackupEnabled: s.cfg.BackupEnabled,
ConfigPath: s.cfg.ConfigPath, ConfigPath: s.cfg.ConfigPath,
ScreenPublic: s.cfg.ScreenPublic,
Addrs: LocalAddrs(), Addrs: LocalAddrs(),
Port: port, Port: port,
Uptime: humanDuration(time.Since(s.started)), Uptime: humanDuration(time.Since(s.started)),
@@ -153,118 +145,6 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "settings", p) s.render(w, r, "settings", p)
} }
type userFormData struct {
Target *store.User
}
// isSuperAdmin 只有一个超级管理员(id=1)可以修改他人资料。
func isSuperAdmin(u *store.User) bool { return u != nil && u.ID == 1 }
// handleUserEditPage 超管编辑其他管理员。
func (s *Server) handleUserEditPage(w http.ResponseWriter, r *http.Request) {
me := s.currentUser(r)
if !isSuperAdmin(me) {
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
return
}
id := pathID(r)
if id == me.ID {
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
return
}
u, err := s.db.UserByID(id)
if err != nil {
s.redirect(w, r, "/settings", "账户不存在")
return
}
p := s.newPage(r, "编辑管理员", "settings")
p.D = &userFormData{Target: u}
s.render(w, r, "user_form", p)
}
// handleUserEditSave 保存超管对其他管理员的修改。
func (s *Server) handleUserEditSave(w http.ResponseWriter, r *http.Request) {
me := s.currentUser(r)
if !isSuperAdmin(me) {
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
return
}
id := pathID(r)
if id == me.ID {
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
return
}
u, err := s.db.UserByID(id)
if err != nil {
s.redirect(w, r, "/settings", "账户不存在")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
displayName := strings.TrimSpace(r.PostFormValue("display_name"))
password := r.PostFormValue("password")
password2 := r.PostFormValue("password2")
back := "/settings/users/" + itoa(id) + "/edit"
if l := len([]rune(username)); l < 3 || l > 32 {
s.redirect(w, r, back, "用户名长度需在 3-32 个字符之间")
return
}
if len([]rune(displayName)) > 32 {
s.redirect(w, r, back, "显示名称不能超过 32 个字符")
return
}
if password != "" {
if len([]rune(password)) < 6 {
s.redirect(w, r, back, "新密码至少 6 位")
return
}
if password != password2 {
s.redirect(w, r, back, "两次输入的新密码不一致")
return
}
}
if err := s.db.UpdateUserByAdmin(id, username, displayName, password); err != nil {
msg := "保存失败:" + err.Error()
if errors.Is(err, store.ErrDuplicate) {
msg = "用户名已被占用"
}
s.redirect(w, r, back, msg)
return
}
detail := u.Username + " → " + username
if displayName != u.DisplayName {
detail += ";昵称 " + u.DisplayName + " → " + displayName
}
if password != "" {
detail += ";已重置密码并强制下线"
}
s.logOp(r, "修改管理员资料", "user", id, detail)
s.redirect(w, r, "/settings", "已更新「"+username+"」的资料")
}
// handleUserColor 设置管理员(打手)颜色。
func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
color, err := store.NormalizeColor(r.PostFormValue("color"))
if err != nil {
s.redirect(w, r, "/settings", "颜色格式不正确")
return
}
if err := s.db.SetUserColor(id, color); err != nil {
s.redirect(w, r, "/settings", "保存颜色失败:"+err.Error())
return
}
name := ""
if u, err := s.db.UserByID(id); err == nil {
name = u.DisplayName
if name == "" {
name = u.Username
}
}
s.logOp(r, "设置打手颜色", "user", id, name+" → "+color)
s.redirect(w, r, "/settings", "已更新 "+name+" 的颜色")
}
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
warn := s.formInt(r, "warn_days", 3) warn := s.formInt(r, "warn_days", 3)
if warn < 0 { if warn < 0 {
@@ -273,18 +153,8 @@ func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
if warn > 60 { if warn > 60 {
warn = 60 warn = 60
} }
refresh := s.formInt(r, "screen_refresh", 30)
if refresh < 5 {
refresh = 5
}
if refresh > 600 {
refresh = 600
}
_ = s.db.SetSetting("warn_days", strconv.Itoa(warn)) _ = s.db.SetSetting("warn_days", strconv.Itoa(warn))
_ = s.db.SetSetting("screen_refresh", strconv.Itoa(refresh)) s.logOp(r, "修改设置", "settings", 0, fmt.Sprintf("预警 %d 天", warn))
s.logOp(r, "修改设置", "settings", 0,
fmt.Sprintf("预警 %d 天、大屏刷新 %d 秒", warn, refresh))
s.redirect(w, r, "/settings", "设置已保存") s.redirect(w, r, "/settings", "设置已保存")
} }
@@ -317,58 +187,6 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, "/settings", "密码已更新") s.redirect(w, r, "/settings", "密码已更新")
} }
func (s *Server) handleUserCreate(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username"))
display := strings.TrimSpace(r.PostFormValue("display_name"))
pw := r.PostFormValue("password")
switch {
case len(username) < 3:
s.redirect(w, r, "/settings", "用户名至少 3 个字符")
return
case len(pw) < 6:
s.redirect(w, r, "/settings", "密码至少 6 位")
return
}
if display == "" {
display = username
}
u, err := s.db.CreateUser(username, display, pw)
if err != nil {
if err == store.ErrDuplicate {
s.redirect(w, r, "/settings", "用户名已存在")
return
}
s.redirect(w, r, "/settings", "创建失败:"+err.Error())
return
}
s.logOp(r, "新增管理员", "user", u.ID, "新增管理员 "+username)
s.redirect(w, r, "/settings", "管理员 "+username+" 已创建")
}
func (s *Server) handleUserToggle(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
me := s.currentUser(r)
if me != nil && me.ID == id {
s.redirect(w, r, "/settings", "不能停用自己的账户")
return
}
target, err := s.db.UserByID(id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.db.SetUserActive(id, !target.IsActive); err != nil {
s.redirect(w, r, "/settings", "操作失败:"+err.Error())
return
}
action := "停用账户"
if !target.IsActive {
action = "启用账户"
}
s.logOp(r, action, "user", id, action+" "+target.Username)
s.redirect(w, r, "/settings", action+"成功")
}
func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) {
path, err := s.db.BackupNow(s.cfg.BackupKeep) path, err := s.db.BackupNow(s.cfg.BackupKeep)
if err != nil { if err != nil {
+8 -31
View File
@@ -35,7 +35,7 @@ func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新增套餐", "packages") p := s.newPage(r, "新增套餐", "packages")
tags, _ := s.db.PackageTags() tags, _ := s.db.PackageTags()
p.D = &packageFormData{ p.D = &packageFormData{
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"}, Package: &store.Package{DefaultAmount: 30, Color: "#3B6EA5"},
IsNew: true, IsNew: true,
Tags: tags, Tags: tags,
} }
@@ -45,7 +45,6 @@ func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
func readPackageForm(r *http.Request) *store.Package { func readPackageForm(r *http.Request) *store.Package {
p := &store.Package{ p := &store.Package{
Name: strings.TrimSpace(r.PostFormValue("name")), Name: strings.TrimSpace(r.PostFormValue("name")),
Unit: strings.TrimSpace(r.PostFormValue("unit")),
DefaultAmount: 0, DefaultAmount: 0,
Color: strings.TrimSpace(r.PostFormValue("color")), Color: strings.TrimSpace(r.PostFormValue("color")),
Note: strings.TrimSpace(r.PostFormValue("note")), Note: strings.TrimSpace(r.PostFormValue("note")),
@@ -63,43 +62,20 @@ func readPackageForm(r *http.Request) *store.Package {
p.HasPrice = true p.HasPrice = true
} }
} }
if p.Unit == "" {
p.Unit = domain.UnitDay
}
p.CalendarSeparate = r.PostFormValue("calendar_separate") != "" p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
p.Tag = strings.TrimSpace(r.PostFormValue("tag")) p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
if p.Unit == "season" {
p.Season = true
p.Unit = domain.UnitDay
}
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"))
return p return p
} }
func validatePackage(p *store.Package) string { func validatePackage(p *store.Package) string {
if p.Season {
if p.SeasonStart == "" || p.SeasonEnd == "" {
return "包赛季套餐必须填写赛季开始日和结束日"
}
if p.SeasonEnd < p.SeasonStart {
return "赛季结束日不能早于开始日"
}
}
if p.Name == "" { if p.Name == "" {
return "套餐名称不能为空" return "套餐名称不能为空"
} }
if len([]rune(p.Name)) > 32 { if len([]rune(p.Name)) > 32 {
return "套餐名称最长 32 字" return "套餐名称最长 32 字"
} }
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
return "套餐类型不正确"
}
if p.DefaultAmount <= 0 { if p.DefaultAmount <= 0 {
return "默认天数/次数必须大于 0" return "默认天数必须大于 0"
} }
if p.Color == "" || !strings.HasPrefix(p.Color, "#") { if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
return "请选择套餐颜色" return "请选择套餐颜色"
@@ -163,13 +139,14 @@ func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Requ
if start == "" { if start == "" {
start = domain.Date(domain.Today()) start = domain.Date(domain.Today())
} }
if _, err := domain.ParseDate(start); err != nil { if start != "" {
s.redirect(w, r, back, "开始日期格式不正确") if _, err := domain.ParseDate(start); err != nil {
return s.redirect(w, r, back, "开始日期格式不正确")
return
}
} }
end := strings.TrimSpace(r.PostFormValue("end_date")) end := strings.TrimSpace(r.PostFormValue("end_date"))
days := s.formInt(r, "days", 0) days := s.formInt(r, "days", 0)
count := s.formInt(r, "total_count", 0)
u := s.currentUser(r) u := s.currentUser(r)
var op int64 var op int64
if u != nil { if u != nil {
@@ -186,7 +163,7 @@ func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Requ
} }
_, err = s.db.CreateSub(store.CreateSubInput{ _, err = s.db.CreateSub(store.CreateSubInput{
CustomerID: cid, PackageID: id, StartDate: start, EndDate: end, CustomerID: cid, PackageID: id, StartDate: start, EndDate: end,
Days: days, TotalCount: count, OperatorID: op, Days: days, OperatorID: op,
}) })
if err != nil { if err != nil {
if strings.Contains(err.Error(), "已订阅") { if strings.Contains(err.Error(), "已订阅") {
-300
View File
@@ -1,300 +0,0 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type screenDay struct {
Date string
Day int
InMonth bool
IsToday bool
IsPast bool
Count int
Done int
Urgent int
Heat int
// Strip 打手色竖条(去重)
Strip []string
}
type screenTask struct {
TaskRow
}
type screenData struct {
Stats *store.Stats
MonthTitle string
Year, Month int
Weeks [][]screenDay
Weekdays []string
Top []SubView
DueToday []SubView
TodayList []screenTask
Recent []store.Adjustment
TodayTasks int
TodayDone int
TodayUrgent int
Refresh int
Version string
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, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
list = append(list, cs)
}
return list, checkins, urgent, nil
}
func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
pkgID := int64(s.queryInt(r, "pkg", 0))
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
heatMode := r.URL.Query().Get("heat")
if heatMode != "due" {
heatMode = "task"
}
stats, err := s.db.ScreenStats(today, warn)
if err != nil {
http.Error(w, "读取大屏数据失败", http.StatusInternalServerError)
return
}
data := &screenData{
Stats: stats,
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
Year: today.Year(),
Month: int(today.Month()),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Top: subViews(stats.Top, today, warn),
Refresh: s.screenRefresh(),
Version: s.cfg.Version,
HeatMode: heatMode,
PkgID: pkgID,
Tag: tag,
FilterQuery: "pkg=" + itoa(pkgID),
}
// 今日到期(可按套餐过滤)
dueToday := []store.Subscription{}
for _, sub := range stats.DueTodaySubs {
if pkgID > 0 && sub.PackageID != pkgID {
continue
}
dueToday = append(dueToday, sub)
}
data.DueToday = subViews(dueToday, today, warn)
// 本月热力:默认统计“每日待完成的单子”,可切换到“到期”
monthStart := domain.MonthStart(today.Year(), today.Month())
monthEnd := domain.MonthEnd(today.Year(), today.Month())
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag)
if err != nil {
http.Error(w, "读取大屏日历失败", http.StatusInternalServerError)
return
}
for wi := 0; wi < weeks; wi++ {
week := make([]screenDay, 0, 7)
for di := 0; di < 7; di++ {
d := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(d)
count, doneN, urgentN := 0, 0, 0
strip := []string{}
seenColor := map[string]bool{}
for _, cs := range list {
if heatMode == "due" {
if cs.sub.EndDate == ds {
count++
}
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
}
week = append(week, screenDay{
Date: ds,
Day: d.Day(),
InMonth: d.Month() == today.Month(),
IsToday: ds == domain.Date(today),
IsPast: d.Before(today),
Count: count + doneN,
Done: doneN,
Urgent: urgentN,
Heat: heatLevel(count),
Strip: strip,
})
}
data.Weeks = append(data.Weeks, week)
}
// 今日待打(加急置顶)
rows, err := s.pendingTasks(domain.Date(today), nil, pkgID, tag)
if err == nil {
for _, row := range rows {
if row.Done || row.Skipped {
continue
}
data.TodayList = append(data.TodayList, screenTask{TaskRow: row})
if row.Urgent {
data.TodayUrgent++
}
}
}
data.TodayTasks = len(data.TodayList)
for _, row := range rows {
if row.Done {
data.TodayDone++
}
}
// 最近追加(底部细条)
if adj, err := s.db.RecentAdjustments(6); err == nil {
data.Recent = adj
}
// 页签:按标签合并 + 独立显示另开
pkgs, _ := s.db.ListPackages(true)
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, nil, 0)
for i := range data.Tabs {
t := &data.Tabs[i]
href := "/screen?heat=" + heatMode
if t.Tag != "" {
href += "&tag=" + url.QueryEscape(t.Tag)
t.Active = t.Tag == tag
} else if t.ID > 0 {
href += "&pkg=" + itoa(t.ID)
t.Active = t.ID == pkgID
} else {
t.Active = tag == "" && pkgID == 0
}
t.Href = href
}
if tag != "" {
data.FilterQuery = "tag=" + url.QueryEscape(tag)
}
data.Theme = themeFromRequest(r)
p := s.newPage(r, "大屏", "screen")
p.Theme = data.Theme
p.D = data
s.render(w, r, "screen", p)
}
// handleScreenDay 返回某天的待办明细,供大屏悬停浮层使用。
func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if _, err := domain.ParseDate(date); err != nil {
http.Error(w, "日期不正确", http.StatusBadRequest)
return
}
pkgID := int64(s.queryInt(r, "pkg", 0))
rows, err := s.pendingTasks(date, nil, pkgID, strings.TrimSpace(r.URL.Query().Get("tag")))
if err != nil {
http.Error(w, "读取失败", http.StatusInternalServerError)
return
}
type item struct {
Tail string `json:"tail"`
Name string `json:"name"`
Pkg string `json:"pkg"`
Color string `json:"color"`
Remain string `json:"remain"`
Urgent bool `json:"urgent"`
Done bool `json:"done"`
Who string `json:"who"`
WhoCol string `json:"who_color"`
}
out := struct {
Date string `json:"date"`
Total int `json:"total"`
Done int `json:"done"`
Urgent int `json:"urgent"`
More int `json:"more"`
Items []item `json:"items"`
}{Date: date}
const limit = 60
for _, row := range rows {
out.Total++
if row.Done || row.Skipped {
out.Done++
}
if row.Urgent {
out.Urgent++
}
if len(out.Items) >= limit {
out.More++
continue
}
out.Items = append(out.Items, item{
Tail: row.CustomerTail,
Name: row.CustomerName,
Pkg: row.PackageName,
Color: row.PackageColor,
Remain: row.RemainText,
Urgent: row.Urgent,
Done: row.Done || row.Skipped,
Who: row.AssigneeLabel(),
WhoCol: row.AssigneeHex(),
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(out)
}
+6 -51
View File
@@ -72,27 +72,15 @@ func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
note := strings.TrimSpace(r.PostFormValue("note")) note := strings.TrimSpace(r.PostFormValue("note"))
back := backURL(r, "/customers/"+itoa(sub.CustomerID)) back := backURL(r, "/customers/"+itoa(sub.CustomerID))
if sub.Unit == domain.UnitDay { newEnd := strings.TrimSpace(r.PostFormValue("new_end_date"))
newEnd := strings.TrimSpace(r.PostFormValue("new_end_date")) plan, err := s.db.ExtendDays(id, newEnd, note, opID)
plan, err := s.db.ExtendDays(id, newEnd, note, opID) if err != nil {
if err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return
}
s.logOp(r, "追加时长", "subscription", id, fmt.Sprintf("%s 追加 %d 天:%s → %s(起点 %s)",
sub.SubTitle(), plan.Days, sub.EndDate, domain.Date(plan.NewEnd), domain.Date(plan.Base)))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 天,新结束日 %s", plan.Days, domain.Date(plan.NewEnd)))
return
}
add := s.formInt(r, "add_count", 0)
optEnd := strings.TrimSpace(r.PostFormValue("new_end_opt"))
if err := s.db.AddCount(id, add, optEnd, note, opID); err != nil {
s.redirect(w, r, back+"#extend", "追加失败:"+err.Error()) s.redirect(w, r, back+"#extend", "追加失败:"+err.Error())
return return
} }
s.logOp(r, "追加次数", "subscription", id, fmt.Sprintf("%s 追加 %d 次", sub.SubTitle(), add)) s.logOp(r, "追加时长", "subscription", id, fmt.Sprintf("%s 追加 %d 天:%s → %s(起点 %s)",
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 次", add)) sub.SubTitle(), plan.Days, sub.EndDate, domain.Date(plan.NewEnd), domain.Date(plan.Base)))
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 天,新结束日 %s", plan.Days, domain.Date(plan.NewEnd)))
} }
func (s *Server) handleSubAction(action string) http.HandlerFunc { func (s *Server) handleSubAction(action string) http.HandlerFunc {
@@ -160,19 +148,6 @@ func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
if u != nil { if u != nil {
opID = u.ID opID = u.ID
} }
if sub.Season {
if status == "skipped" {
s.redirect(w, r, back, "包赛季不能跳过,请直接完成或结束")
return
}
if err := s.db.SeasonComplete(id, date); err != nil {
s.redirect(w, r, back, "完成赛季失败:"+err.Error())
return
}
s.logOp(r, "赛季完成", "subscription", id, fmt.Sprintf("%s 赛季整段完成(%s)", sub.SubTitle(), date))
s.redirect(w, r, back, "已把 "+sub.CustomerTail+" "+sub.CustomerName+" 的赛季标记为完成")
return
}
if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil { if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil {
s.redirect(w, r, back, "打卡失败:"+err.Error()) s.redirect(w, r, back, "打卡失败:"+err.Error())
return return
@@ -216,23 +191,3 @@ func (s *Server) handleSubDelete(w http.ResponseWriter, r *http.Request) {
s.logOp(r, "删除订阅", "subscription", id, sub.SubTitle()+"(含打卡与追加记录)") s.logOp(r, "删除订阅", "subscription", id, sub.SubTitle()+"(含打卡与追加记录)")
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已删除订阅「"+sub.PackageName+"」") s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已删除订阅「"+sub.PackageName+"」")
} }
// handleSeasonDone 从订阅卡片直接完成整段赛季。
func (s *Server) handleSeasonDone(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
sub, err := s.db.Sub(id)
if err != nil {
http.NotFound(w, r)
return
}
date := strings.TrimSpace(r.PostFormValue("date"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
if err := s.db.SeasonComplete(id, date); err != nil {
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "完成失败:"+err.Error())
return
}
s.logOp(r, "赛季完成", "subscription", id, sub.SubTitle()+" 整段完成("+date+")")
s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已把赛季标记为完成")
}
+9 -4
View File
@@ -9,7 +9,7 @@ import (
"daili/internal/domain" "daili/internal/domain"
) )
// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余), // pendingTasks 返回某天“应服务”的订阅行(在区间内且未暂停),
// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。 // customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。
func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64, tag string) ([]TaskRow, error) { func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64, tag string) ([]TaskRow, error) {
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag) subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag)
@@ -23,7 +23,7 @@ func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int
if customerIDs != nil && !customerIDs[sub.CustomerID] { if customerIDs != nil && !customerIDs[sub.CustomerID] {
continue continue
} }
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)} cs := calSub{sub: sub, pauses: pauses[sub.ID], checkins: checkins[sub.ID]}
if sub.EndDate != "" { if sub.EndDate != "" {
cs.hasEnd = true cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today) cs.end = domain.ParseDateOr(sub.EndDate, today)
@@ -45,6 +45,11 @@ func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int
return rows, nil return rows, nil
} }
// completeTask 完成某个订阅的某一天。
func (s *Server) completeTask(subID int64, date string, operatorID int64) error {
return s.db.SaveCheckin(subID, date, "done", "", operatorID)
}
// handleQuickCheckin 输入手机尾号直接完成当日服务: // handleQuickCheckin 输入手机尾号直接完成当日服务:
// 命中 1 项直接完成;命中多项则跳到日历的选择面板。 // 命中 1 项直接完成;命中多项则跳到日历的选择面板。
func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
@@ -101,7 +106,7 @@ func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
if u != nil { if u != nil {
op = u.ID op = u.ID
} }
if err := s.db.SaveCheckin(row.ID, date, "done", "", op); err != nil { if err := s.completeTask(row.ID, date, op); err != nil {
s.redirect(w, r, back, "完成失败:"+err.Error()) s.redirect(w, r, back, "完成失败:"+err.Error())
return return
} }
@@ -147,7 +152,7 @@ func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) {
failed++ failed++
continue continue
} }
if err := s.db.SaveCheckin(id, date, "done", "", op); err != nil { if err := s.completeTask(id, date, op); err != nil {
failed++ failed++
lastErr = err.Error() lastErr = err.Error()
continue continue
+60 -60
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"embed" "embed"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@@ -22,7 +23,7 @@ import (
"daili/internal/store" "daili/internal/store"
) )
//go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html //go:embed templates/_layout.html templates/_partials.html templates/pages/*.html
var templateFS embed.FS var templateFS embed.FS
//go:embed static //go:embed static
@@ -43,18 +44,18 @@ type Config struct {
BackupKeep int BackupKeep int
BackupDir string BackupDir string
BackupEnabled bool BackupEnabled bool
ScreenPublic bool
ConfigPath string ConfigPath string
Version string Version string
} }
// Server 是应用服务。 // Server 是应用服务。
type Server struct { type Server struct {
db *store.DB db *store.DB
cfg Config cfg Config
tpl *renderer tpl *renderer
handler http.Handler handler http.Handler
started time.Time started time.Time
assetVersion string
} }
type ctxKey int type ctxKey int
@@ -67,11 +68,33 @@ func New(db *store.DB, cfg Config) (*Server, error) {
if err != nil { if err != nil {
return nil, err 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() s.handler = s.routes()
return s, nil 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 处理器。 // Handler 返回 HTTP 处理器。
func (s *Server) Handler() http.Handler { return s.handler } func (s *Server) Handler() http.Handler { return s.handler }
@@ -82,7 +105,7 @@ func (s *Server) routes() http.Handler {
if err != nil { if err != nil {
panic(err) 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) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
@@ -104,7 +127,6 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail)) mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage)) mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate)) mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
mux.HandleFunc("POST /customers/{id}/assignee", s.auth(s.handleCustomerAssignee))
mux.HandleFunc("POST /customers/{id}/archive", s.auth(s.handleCustomerArchive)) mux.HandleFunc("POST /customers/{id}/archive", s.auth(s.handleCustomerArchive))
mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore)) mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore))
mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete)) mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete))
@@ -116,7 +138,6 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close"))) mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen"))) mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete)) mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete))
mux.HandleFunc("POST /subscriptions/{id}/season-done", s.auth(s.handleSeasonDone))
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave)) mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete)) mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch)) mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
@@ -130,32 +151,26 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive)) mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail)) mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail))
mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe)) mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe))
if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /screen", s.handleScreen)
mux.HandleFunc("GET /api/screen/day", s.handleScreenDay)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
mux.HandleFunc("GET /api/screen/day", s.auth(s.handleScreenDay))
}
mux.HandleFunc("GET /logs", s.auth(s.handleLogs)) mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
mux.HandleFunc("GET /settings", s.auth(s.handleSettings)) mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave)) mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange)) mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor))
mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage))
mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow)) mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport)) mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
return s.noCacheHTML(mux) 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) { 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) h.ServeHTTP(w, r)
}) })
} }
@@ -280,11 +295,6 @@ func newRenderer() (*renderer, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
screenBase, err := template.New("screen").Funcs(funcs).ParseFS(templateFS,
"templates/_screen_layout.html", "templates/_partials.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{} pages := map[string]*template.Template{}
entries, err := fs.Glob(templateFS, "templates/pages/*.html") entries, err := fs.Glob(templateFS, "templates/pages/*.html")
@@ -293,13 +303,7 @@ func newRenderer() (*renderer, error) {
} }
for _, p := range entries { for _, p := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html") name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html")
var baseSet *template.Template clone, err := base.Clone()
if name == "screen" {
baseSet = screenBase
} else {
baseSet = base
}
clone, err := baseSet.Clone()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -337,14 +341,19 @@ type page struct {
TodayStr string TodayStr string
WarnDays int WarnDays int
Version string Version string
Query string // AssetVersion 是内嵌静态资源的内容指纹,用于 /static/*.css?v=... 的长缓存
Next string AssetVersion string
DueToday int Query string
Due3 int Name string
Due7 int InlineError string
Addr string InvalidField string
Theme string // "" = 跟随系统;light / dark Next string
D any DueToday int
Due3 int
Due7 int
Addr string
Theme string // "" = 跟随系统;light / dark
D any
} }
func (s *Server) warnDays() int { func (s *Server) warnDays() int {
@@ -355,14 +364,6 @@ func (s *Server) warnDays() int {
return v return v
} }
func (s *Server) screenRefresh() int {
v, err := strconv.Atoi(s.db.Setting("screen_refresh", "30"))
if err != nil || v < 5 {
return 30
}
return v
}
func (s *Server) newPage(r *http.Request, title, nav string) *page { func (s *Server) newPage(r *http.Request, title, nav string) *page {
u := s.currentUser(r) u := s.currentUser(r)
today := domain.Today() today := domain.Today()
@@ -374,9 +375,11 @@ func (s *Server) newPage(r *http.Request, title, nav string) *page {
TodayStr: domain.Date(today), TodayStr: domain.Date(today),
WarnDays: s.warnDays(), WarnDays: s.warnDays(),
Version: s.cfg.Version, Version: s.cfg.Version,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"), AssetVersion: s.assetVersion,
Theme: themeFromRequest(r), Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
Theme: themeFromRequest(r),
} }
if u != nil { if u != nil {
if due, due3, due7, err := s.db.Badges(today); err == nil { if due, due3, due7, err := s.db.Badges(today); err == nil {
@@ -444,9 +447,6 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *
return return
} }
layout := "layout" layout := "layout"
if name == "screen" {
layout = "screenlayout"
}
var buf bytes.Buffer var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil { if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil {
log.Printf("渲染 %s 失败: %v", name, err) log.Printf("渲染 %s 失败: %v", name, err)
+1051 -963
View File
File diff suppressed because it is too large Load Diff
+56 -184
View File
@@ -1,16 +1,21 @@
/* 代打台 · 交互增强(无框架,全部渐进增强) */ /* 代打台-L · 交互增强(无框架,全部渐进增强) */
/* Powered by Baka春秋 */
(function () { (function () {
'use strict'; 'use strict';
/* 提示条自动淡出 */ /* ---------- 工具:本地日期,禁止用 toISOString 做“自然日”计算 ---------- */
function parseDay(s) { return new Date(s + 'T00:00:00'); }
function diffDays(a, b) { return Math.round((b.getTime() - a.getTime()) / 86400000); }
/* ---------- 提示条:自动淡出(.flash 固定定位,不会顶动页面) ---------- */
document.querySelectorAll('[data-flash]').forEach(function (el) { document.querySelectorAll('[data-flash]').forEach(function (el) {
setTimeout(function () { setTimeout(function () {
el.classList.add('hide'); el.classList.add('hide');
setTimeout(function () { el.remove(); }, 450); setTimeout(function () { el.remove(); }, 400);
}, 4200); }, 4200);
}); });
/* 按 / 聚焦搜索框 */ /* ---------- 按 / 聚焦搜索框 ---------- */
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return; if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (document.activeElement && document.activeElement.tagName) || ''; var tag = (document.activeElement && document.activeElement.tagName) || '';
@@ -19,17 +24,14 @@
if (s) { e.preventDefault(); s.focus(); s.select(); } if (s) { e.preventDefault(); s.focus(); s.select(); }
}); });
/* 危险操作确认 */ /* ---------- 危险操作确认 ---------- */
document.querySelectorAll('form[data-confirm]').forEach(function (f) { document.querySelectorAll('form[data-confirm]').forEach(function (f) {
f.addEventListener('submit', function (e) { f.addEventListener('submit', function (e) {
if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault(); 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) { document.querySelectorAll('form[data-extend]').forEach(function (form) {
var base = parseDay(form.getAttribute('data-base')); var base = parseDay(form.getAttribute('data-base'));
var today = parseDay(form.getAttribute('data-today')); var today = parseDay(form.getAttribute('data-today'));
@@ -63,104 +65,75 @@
if (input.value) paint(input.value); if (input.value) paint(input.value);
}); });
/* 新订阅表单:按套餐类型切换「天数 / 次数 / 终止日」 */ /* ---------- 新订阅表单:选套餐时带出默认天数 ---------- */
document.querySelectorAll('form[data-sub-form]').forEach(function (form) { document.querySelectorAll('form[data-sub-form]').forEach(function (form) {
var sel = form.querySelector('[data-pkg-select]'); var sel = form.querySelector('[data-pkg-select]');
var daysWrap = form.querySelector('[data-days-wrap]'); var daysWrap = form.querySelector('[data-days-wrap]');
var countWrap = form.querySelector('[data-count-wrap]'); if (!sel || !daysWrap) return;
var endWrap = form.querySelector('[data-end-wrap]'); var daysInput = daysWrap.querySelector('input');
var startWrap = form.querySelector('[data-start-wrap]');
var startInput = form.querySelector('input[name="start_date"]');
if (!sel) return;
function sync() { sel.addEventListener('change', function () {
var opt = sel.options[sel.selectedIndex]; var opt = sel.options[sel.selectedIndex];
var unit = opt && opt.getAttribute('data-unit'); var amount = opt && parseInt(opt.getAttribute('data-amount') || '0', 10);
var amount = opt && opt.getAttribute('data-amount'); if (amount > 0 && daysInput) daysInput.value = amount;
var validity = opt && parseInt(opt.getAttribute('data-validity') || '0', 10); });
var endInput = endWrap && endWrap.querySelector('input');
if (unit === 'season') {
if (daysWrap) daysWrap.hidden = true;
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = true;
if (startWrap) startWrap.hidden = true;
if (startInput) startInput.required = false;
return;
}
if (startWrap) startWrap.hidden = false;
if (startInput) startInput.required = true;
if (unit === 'count') {
if (daysWrap) daysWrap.hidden = true;
if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; }
if (endWrap) endWrap.hidden = false;
if (validity > 0 && endInput && !endInput.value && startInput && startInput.value) {
var d = new Date(startInput.value + 'T00:00:00');
d.setDate(d.getDate() + validity - 1);
endInput.value = d.toISOString().slice(0, 10);
}
} 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;
} else {
if (daysWrap) daysWrap.hidden = false;
if (countWrap) countWrap.hidden = true;
if (endWrap) endWrap.hidden = true;
}
}
sel.addEventListener('change', sync);
sync();
}); });
/* 套餐表单:切换默认量单位文案 */ /* ---------- 批量开通:表头全选 + 半选状态 ---------- */
document.querySelectorAll('form[data-package-form]').forEach(function (form) { document.querySelectorAll('[data-select-all]').forEach(function (box) {
var label = form.querySelector('[data-unit-label]'); var form = box.closest('form') || document;
var radios = form.querySelectorAll('input[name="unit"]'); var boxes = form.querySelectorAll('input[name="customer_ids"]');
function sync() {
var v = 'day'; function refresh() {
radios.forEach(function (r) { if (r.checked) v = r.value; }); var total = 0, checked = 0;
if (label) label.textContent = v === 'count' ? '次' : '天'; boxes.forEach(function (c) {
var seasonWrap = form.querySelector('[data-season-wrap]'); if (c.disabled) return;
var validityWrap = form.querySelector('[data-validity-wrap]'); total++;
if (seasonWrap) { if (c.checked) checked++;
seasonWrap.hidden = v !== 'season'; });
seasonWrap.querySelectorAll('input').forEach(function (i) { i.required = v === 'season'; }); box.checked = total > 0 && checked === total;
} box.indeterminate = checked > 0 && checked < total;
if (validityWrap) { validityWrap.hidden = v !== 'count'; }
} }
radios.forEach(function (r) { r.addEventListener('change', sync); });
sync(); 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();
}); });
})(); })();
/* ---------- 亮色 / 暗色切换 ---------- /* ---------- 亮色 / 暗色切换 ----------
偏好写 cookie,服务端首屏直接带 data-theme,不闪。 唯一状态源是 <html data-bs-theme>:服务端按 Cookie 直出,
没有 cookie 时跟随系统(由 CSS 的 prefers-color-scheme 决定)。 */ 没有 Cookie 时由 <head> 里的内联脚本按系统偏好补上。 */
(function () { (function () {
'use strict'; 'use strict';
var KEY = 'daili_theme'; var KEY = 'daili_theme';
var root = document.documentElement;
var btns = document.querySelectorAll('[data-theme-toggle]'); var btns = document.querySelectorAll('[data-theme-toggle]');
if (!btns.length) return; if (!btns.length) return;
function effective() { function current() {
var attr = document.documentElement.getAttribute('data-theme'); return root.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light';
if (attr === 'light' || attr === 'dark') return attr; }
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; function paint() {
var dark = current() === 'dark';
btns.forEach(function (b) { b.setAttribute('aria-pressed', dark ? 'true' : 'false'); });
} }
Array.prototype.forEach.call(btns, function (btn) { btns.forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var next = effective() === 'dark' ? 'light' : 'dark'; var next = current() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next); root.setAttribute('data-bs-theme', next);
document.cookie = KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax'; document.cookie = KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax';
paint();
}); });
}); });
paint();
})(); })();
/* ---------- 触摸小键盘 + 抽屉时钟 + 更多菜单 ---------- */ /* ---------- 抽屉时钟 + 更多菜单 ---------- */
(function () { (function () {
'use strict'; 'use strict';
@@ -170,115 +143,14 @@
var d = new Date(); var d = new Date();
var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
var t = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); var t = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
Array.prototype.forEach.call(clocks, function (el) { el.textContent = t; }); clocks.forEach(function (el) { el.textContent = t; });
} }
if (clocks.length) { tick(); setInterval(tick, 1000); } if (clocks.length) { tick(); setInterval(tick, 1000); }
/* 小键盘:输入手机尾号
关键点:鼠标按下时阻止默认行为,否则按钮会先抢走输入框焦点;
每次按键后再把焦点和光标送回输入框末尾。 */
Array.prototype.forEach.call(document.querySelectorAll('[data-keypad]'), function (pad) {
// 键盘是表单的兄弟节点(不在 <form> 内),所以先找输入框,再用 input.form 反查表单
var scope = pad.closest('.drawer-block') || pad.parentElement || document;
var input = scope.querySelector('input[name="tail"]');
if (!input) { return; }
var form = input.form;
if (!form) { return; }
function keepFocus() {
try { input.focus({ preventScroll: true }); } catch (err) { input.focus(); }
var end = input.value.length;
try { input.setSelectionRange(end, end); } catch (err) { /* number 类型时忽略 */ }
}
// mousedown 会触发“按钮获得焦点”,先拦下来;pointerdown 再兜底一次
pad.addEventListener('mousedown', function (e) {
e.preventDefault();
keepFocus();
});
pad.addEventListener('pointerdown', function () {
// 这里不能 preventDefault:会连带取消后面的 click 事件
keepFocus();
});
pad.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-key]');
if (!btn) return;
var key = btn.getAttribute('data-key');
if (key === 'back') {
input.value = input.value.slice(0, -1);
} else if (key === 'ok') {
if (input.value) {
if (form.requestSubmit) { form.requestSubmit(); } else { form.submit(); }
}
return;
} else if (input.value.length < 8) {
input.value += key;
}
keepFocus();
});
});
/* 更多菜单:点开一个时收起其它,点空白处关闭 */ /* 更多菜单:点开一个时收起其它,点空白处关闭 */
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var opened = document.querySelectorAll('.more-actions[open]'); document.querySelectorAll('.more-actions[open]').forEach(function (d) {
Array.prototype.forEach.call(opened, function (d) {
if (!d.contains(e.target)) { d.removeAttribute('open'); } if (!d.contains(e.target)) { d.removeAttribute('open'); }
}); });
}); });
})(); })();
/* ---------- 全选 + 触控/PC 小键盘模式 ---------- */
(function () {
'use strict';
/* 批量开通:表头全选 */
Array.prototype.forEach.call(document.querySelectorAll('[data-select-all]'), function (box) {
box.addEventListener('change', function () {
var form = box.closest('form') || document;
Array.prototype.forEach.call(form.querySelectorAll('input[name="customer_ids"]'), function (c) {
if (!c.disabled) { c.checked = box.checked; }
});
});
});
/* 触控 / PC 模式:触控时显示键盘并禁用输入框(不唤起系统输入法) */
var KEY = 'daili_keypad_mode';
function applyMode(touch) {
Array.prototype.forEach.call(document.querySelectorAll('[data-keypad]'), 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);
Array.prototype.forEach.call(document.querySelectorAll('[data-keypad-toggle]'), 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(); }
}
});
});
})();
-411
View File
@@ -1,411 +0,0 @@
/* ============================================================
代打台 · 可视化大屏(记分板)
默认深色;亮色由系统偏好或 [data-theme="light"] 覆盖
============================================================ */
:root {
--bg: #0c0f12;
--panel: #12161b;
--panel-2: #171c22;
--line: #232a32;
--ink: #e9edf1;
--ink-2: #aab4bf;
--muted: #6f7b87;
--accent: #58c1a3;
--accent-2: #8bd9c4;
--danger: #ef6b62;
--warn: #e6ac55;
--ok: #58c1a3;
--glow: 0 0 24px rgba(88, 193, 163, 0.18);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f4f2ec;
--panel: #ffffff;
--panel-2: #faf9f5;
--line: #e0ddd2;
--ink: #1b1e23;
--ink-2: #4a4f57;
--muted: #8a8f97;
--accent: #2f7d68;
--accent-2: #245c4d;
--danger: #b3302a;
--warn: #a0600f;
--ok: #2f7d68;
--glow: 0 0 18px rgba(47, 125, 104, 0.12);
}
}
:root[data-theme="light"] {
--bg: #f4f2ec;
--panel: #ffffff;
--panel-2: #faf9f5;
--line: #e0ddd2;
--ink: #1b1e23;
--ink-2: #4a4f57;
--muted: #8a8f97;
--accent: #2f7d68;
--accent-2: #245c4d;
--danger: #b3302a;
--warn: #a0600f;
--ok: #2f7d68;
--glow: 0 0 18px rgba(47, 125, 104, 0.12);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: "Bahnschrift", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
font-size: 14px;
overflow: hidden;
}
/* 背景:极淡的网格与光晕 */
body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(1200px 600px at 20% -10%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 60%),
linear-gradient(var(--line) 1px, transparent 1px) 0 0 / 100% 40px,
linear-gradient(90deg, var(--line) 1px, transparent 1px) 0 0 / 40px 100%;
opacity: 0.5;
}
.scr {
position: relative; z-index: 1;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto auto auto;
gap: 10px;
height: 100vh;
padding: 12px 16px 8px;
}
/* ---------- 头部 ---------- */
.scr-head { display: flex; align-items: center; gap: 16px; min-height: 40px; }
.scr-brand { display: flex; align-items: baseline; gap: 6px; font-size: 17px; font-weight: 700; letter-spacing: 0.08em; }
.scr-brand .mark {
display: inline-grid; place-items: center;
width: 24px; height: 24px; margin-right: 4px;
background: var(--accent); color: var(--bg);
font-size: 13px; border-radius: 2px;
align-self: center;
}
.scr-brand em { font-style: normal; font-size: 11px; color: var(--muted); letter-spacing: 0.2em; }
.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(--ink-2); text-decoration: none;
border: 1px solid var(--line); border-radius: 999px;
background: var(--panel);
}
.scr-tabs a::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: var(--tab, var(--accent));
}
.scr-tabs a.on { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.scr-tabs a.on::before { background: var(--bg); }
.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(--accent); text-decoration: none;
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line));
border-radius: 999px; background: var(--panel);
}
.scr-switch:hover { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
.scr-theme {
width: 28px; height: 28px; padding: 0;
display: inline-grid; place-items: center;
border: 1px solid var(--line); border-radius: 999px;
background: var(--panel); color: var(--ink-2);
font-size: 13px; cursor: pointer;
}
.scr-theme .ic-sun { display: none; }
:root[data-theme="light"] .scr-theme .ic-sun { display: inline; }
:root[data-theme="light"] .scr-theme .ic-moon { display: none; }
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) .scr-theme .ic-sun { display: inline; }
:root:not([data-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: var(--mono, "Bahnschrift", monospace); font-size: 22px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.scr-clock span { display: block; font-size: 11px; color: var(--muted); }
.scr-links { font-size: 11px; color: var(--muted); }
.scr-links a { color: var(--accent); text-decoration: none; }
/* ---------- KPI ---------- */
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.kpi {
position: relative;
display: flex; align-items: baseline; gap: 6px;
padding: 10px 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 3px;
overflow: hidden;
}
.kpi::after {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--accent);
}
.kpi span { font-size: 12px; color: var(--muted); margin-right: auto; }
.kpi b { font-family: "Bahnschrift", monospace; font-size: 30px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.kpi i { font-style: normal; font-size: 11px; color: var(--muted); }
.kpi-hot::after { background: var(--danger); }
.kpi-hot b { color: var(--danger); }
.kpi-warm::after { background: var(--warn); }
.kpi-warm b { color: var(--warn); }
/* ---------- 主区 ---------- */
.scr-main { display: grid; grid-template-columns: 1.5fr 1fr; gap: 10px; min-height: 0; }
.panel {
display: flex; flex-direction: column; min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 3px;
overflow: hidden;
}
.panel-head {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.panel-head h2 { margin: 0; font-size: 13px; letter-spacing: 0.08em; font-weight: 700; }
.panel-head span { font-size: 11px; color: var(--muted); }
/* 热力 */
.heat-grid { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; padding: 8px 12px; min-height: 0; }
.heat-dow { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; }
.heat-dow span { text-align: center; font-size: 10px; color: var(--muted); }
.heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; }
.heat-cell {
position: relative;
display: flex; align-items: center; justify-content: space-between;
min-height: 0; aspect-ratio: 2.05 / 1;
padding: 4px 7px;
border: 1px solid var(--line);
border-radius: 2px;
background: var(--panel-2);
font-size: 11px; color: var(--muted);
transition: transform 0.1s ease, border-color 0.1s ease;
}
.heat-cell span { font-family: "Bahnschrift", monospace; font-size: 11px; }
.heat-cell b { font-family: "Bahnschrift", monospace; font-size: 12px; color: var(--ink-2); }
.heat-cell.out { opacity: 0.28; }
.heat-cell.past { opacity: 0.66; }
.heat-cell.today { outline: 1px solid var(--accent); outline-offset: -1px; }
.heat-cell.today span { color: var(--accent); font-weight: 700; }
.heat-cell:hover { transform: translateY(-1px); border-color: var(--accent); z-index: 3; }
.heat-cell.h1 { background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); border-color: color-mix(in srgb, var(--accent) 22%, var(--line)); }
.heat-cell.h2 { background: color-mix(in srgb, var(--accent) 24%, var(--panel-2)); }
.heat-cell.h3 { background: color-mix(in srgb, var(--accent) 38%, var(--panel-2)); }
.heat-cell.h4 { background: color-mix(in srgb, var(--accent) 56%, var(--panel-2)); }
.heat-cell.h3 b, .heat-cell.h4 b { color: var(--bg); }
.heat-cell.h3 span, .heat-cell.h4 span { color: color-mix(in srgb, var(--bg) 75%, var(--accent)); }
.cell-urgent {
position: absolute; top: 3px; right: 3px;
width: 5px; height: 5px; border-radius: 50%;
background: var(--danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--danger) 70%, transparent);
}
.heat-legend {
display: flex; align-items: center; gap: 4px;
padding: 6px 14px 10px;
font-size: 10px; color: var(--muted);
}
.heat-legend span { margin-right: 6px; }
.heat-legend i {
width: 14px; height: 9px; border-radius: 1px;
border: 1px solid var(--line);
display: inline-block;
}
.heat-legend i.h0 { background: var(--panel-2); }
.heat-legend i.h1 { background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); }
.heat-legend i.h2 { background: color-mix(in srgb, var(--accent) 24%, var(--panel-2)); }
.heat-legend i.h3 { background: color-mix(in srgb, var(--accent) 38%, var(--panel-2)); }
.heat-legend i.h4 { background: color-mix(in srgb, var(--accent) 56%, var(--panel-2)); }
.heat-legend .legend-hint { margin-left: auto; font-style: normal; opacity: 0.7; }
/* TOP 10 */
.top-list { list-style: none; margin: 0; padding: 6px 10px; overflow: hidden; flex: 1 1 auto; }
.top-item {
display: grid;
grid-template-columns: 22px 54px minmax(0, 1fr) auto auto 56px;
gap: 8px; align-items: center;
padding: 5px 4px;
border-bottom: 1px dashed var(--line);
font-size: 12px;
}
.top-item:last-child { border-bottom: 0; }
.top-item .rank { font-family: "Bahnschrift", monospace; font-size: 11px; color: var(--muted); }
.top-item:nth-child(-n+3) .rank { color: var(--accent); font-weight: 700; }
.top-tail { font-family: "Bahnschrift", monospace; font-size: 14px; font-weight: 700; letter-spacing: 0.03em; }
.top-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
.top-pkg {
font-size: 10px; padding: 1px 6px; border-radius: 2px;
border-left: 2px solid var(--tag, var(--accent));
background: color-mix(in srgb, var(--tag, var(--accent)) 14%, transparent);
color: var(--ink-2); white-space: nowrap;
}
.top-remain { font-family: "Bahnschrift", monospace; font-size: 11px; white-space: nowrap; }
.top-item.lv-today .top-remain { color: var(--danger); font-weight: 700; }
.top-item.lv-warn .top-remain { color: var(--warn); }
.top-bar { height: 4px; background: var(--line); border-radius: 999px; overflow: hidden; }
.top-bar i { display: block; height: 100%; background: var(--accent); }
.top-item.lv-today .top-bar i { background: var(--danger); }
.top-item.lv-warn .top-bar i { background: var(--warn); }
/* ---------- 底部两块 ---------- */
.scr-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; min-height: 150px; max-height: 30vh; }
.today-list { list-style: none; margin: 0; padding: 4px 10px 8px; overflow: auto; }
.today-list li {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 8px; align-items: center;
padding: 5px 6px;
border-bottom: 1px dashed var(--line);
font-size: 12px;
}
.today-list li:last-child { border-bottom: 0; }
.today-list li.urgent {
background: color-mix(in srgb, var(--danger) 9%, transparent);
border-left: 2px solid var(--danger);
padding-left: 8px;
}
.tchip {
display: inline-flex; align-items: baseline; gap: 3px;
padding: 1px 6px;
border-left: 2px solid var(--tag, var(--accent));
background: color-mix(in srgb, var(--tag, var(--accent)) 14%, transparent);
border-radius: 1px; white-space: nowrap;
}
.tchip b { font-family: "Bahnschrift", monospace; font-size: 13px; }
.today-list .pkg { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.today-list .rem { font-family: "Bahnschrift", monospace; font-size: 11px; color: var(--ink-2); white-space: nowrap; }
.tag-urgent {
font-size: 9px; line-height: 1; padding: 2px 4px;
background: var(--danger); color: #fff; border-radius: 2px;
white-space: nowrap;
}
.scr-empty, .scr-empty-inline { color: var(--muted); font-size: 12px; padding: 16px; text-align: center; margin: 0; }
.scr-empty-inline { padding: 0; text-align: left; }
/* ---------- 最近追加 ---------- */
.scr-recent {
display: flex; align-items: center; gap: 10px;
padding: 5px 12px;
background: var(--panel);
border: 1px solid var(--line); border-radius: 3px;
font-size: 11px; color: var(--muted);
overflow: hidden;
}
.recent-label { flex: 0 0 auto; letter-spacing: 0.12em; color: var(--accent); font-size: 10px; }
.recent-track { display: flex; gap: 18px; overflow: hidden; white-space: nowrap; }
.recent-item { color: var(--ink-2); }
.recent-item b { font-family: "Bahnschrift", monospace; font-size: 12px; color: var(--ink); margin-right: 3px; }
.recent-item em { font-style: normal; color: var(--accent); margin-left: 3px; }
.scr-foot { text-align: center; font-size: 10px; color: var(--muted); letter-spacing: 0.06em; }
/* ---------- 当日明细浮层 ---------- */
.day-pop {
position: fixed; z-index: 60;
width: 340px; max-height: 62vh; overflow: auto;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 3px;
box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.7), var(--glow);
font-size: 12px;
pointer-events: none;
}
.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(--ink);
}
.day-pop h4 small { font-weight: 400; color: var(--muted); font-size: 10px; }
.day-pop .pop-meta { display: flex; gap: 10px; margin-bottom: 6px; font-size: 10px; color: var(--muted); }
.day-pop .pop-meta b { color: var(--ink-2); font-family: "Bahnschrift", 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(--line);
}
.day-pop li:last-child { border-bottom: 0; }
.day-pop li.urgent { color: var(--danger); }
.day-pop li.done { opacity: 0.45; text-decoration: line-through; }
.day-pop .pop-tail { font-family: "Bahnschrift", monospace; font-size: 12px; font-weight: 700; }
.day-pop .pop-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
.day-pop .pop-rem { font-size: 10px; color: var(--muted); white-space: nowrap; }
.day-pop .pop-more { margin-top: 6px; font-size: 10px; color: var(--muted); text-align: right; }
.day-pop .pop-empty { color: var(--muted); text-align: center; padding: 8px 0; }
/* ---------- 小屏适配(笔记本投屏也常见) ---------- */
@media (max-width: 1200px) {
.scr-main { grid-template-columns: 1fr 1fr; }
.scr-kpis .kpi b { font-size: 24px; }
.top-item { grid-template-columns: 20px 48px minmax(0, 1fr) auto 48px; }
.top-item .top-pkg { display: none; }
}
@media (max-width: 860px) {
body { overflow: auto; }
.scr { height: auto; }
.scr-main, .scr-bottom { grid-template-columns: 1fr; }
.scr-tabs, .scr-links { display: none; }
}
/* ---------- 打手颜色(大屏) ---------- */
.asg-strip {
position: absolute; left: 0; top: 0; bottom: 0;
display: flex; flex-direction: column;
width: 3px; overflow: hidden;
}
.asg-strip i { display: block; flex: 1 1 0; min-height: 3px; }
.heat-cell { padding-left: 9px; }
.asg {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; color: var(--ink-2); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.asg i { width: 7px; height: 7px; border-radius: 1px; flex: 0 0 auto; }
.asg.none { color: var(--muted); }
.asg.none i { opacity: 0.5; }
.today-list li { grid-template-columns: auto minmax(0, 1fr) auto auto auto; }
.pop-who {
display: inline-block; width: 7px; height: 7px;
border-radius: 1px; margin-right: 4px; vertical-align: 0;
}
-132
View File
@@ -1,132 +0,0 @@
/* 大屏:时钟 + 全屏 + 主题 + 日历悬停明细 */
(function () {
'use strict';
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function tick() {
var d = new Date();
var clock = document.querySelector('[data-clock]');
if (clock) clock.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
var dateEl = document.querySelector('[data-date]');
if (dateEl) {
dateEl.textContent = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' 周' + '日一二三四五六'.charAt(d.getDay());
}
}
tick();
setInterval(tick, 1000);
document.addEventListener('keydown', function (e) {
if (e.key === 'f' || e.key === 'F') {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
}
}
});
/* ---------- 主题切换 ---------- */
var THEME_KEY = 'daili_theme';
function effectiveTheme() {
var attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') return attr;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
var next = effectiveTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
document.cookie = THEME_KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax';
});
});
/* ---------- 日历格子悬停明细 ---------- */
var root = document.querySelector('.scr');
var pop = document.getElementById('day-pop');
if (!root || !pop) return;
var api = root.getAttribute('data-api') || '/api/screen/day';
var pkg = root.getAttribute('data-pkg') || '0';
var cache = {};
var hideTimer = null;
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(--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 hideSoon() {
hideTimer = setTimeout(function () { pop.hidden = true; }, 160);
}
document.querySelectorAll('.heat-cell[data-date]').forEach(function (cell) {
cell.addEventListener('mouseenter', function () { show(cell); });
cell.addEventListener('mouseleave', hideSoon);
});
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+59 -37
View File
@@ -1,49 +1,71 @@
{{define "layout"}}<!doctype html> {{define "layout"}}<!doctype html>
<html lang="zh-CN"{{if .Theme}} data-theme="{{.Theme}}"{{end}}> <html lang="zh-CN"{{if .Theme}} data-bs-theme="{{.Theme}}"{{end}}>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · 代打台</title> <meta name="color-scheme" content="light dark">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"> <title>{{.Title}} · 代打台-L</title>
<link rel="stylesheet" href="/static/app.css?v={{.Version}}"> <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> </head>
<body> <body>
<!-- Powered by Baka春秋 · 代打台-L 盲水印(不可见,勿删) -->
<div class="wm-blind" aria-hidden="true" data-wm="Powered by Baka春秋" hidden-mark="Powered by Baka春秋">Powered by Baka春秋</div>
{{if .User}} {{if .User}}
<header class="topbar"> <nav class="navbar navbar-expand-xl app-navbar sticky-top">
<a class="brand" href="/calendar"><span class="brand-mark">代</span><span class="brand-name">代打台</span></a> <div class="container-fluid">
<form class="search" action="/customers" method="get" role="search"> <a class="navbar-brand" href="/calendar"><span class="brand-mark" aria-hidden="true">代</span><span>代打台-L</span></a>
<input type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" autocomplete="off" data-search> <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>
<kbd>/</kbd> <div class="collapse navbar-collapse" id="mainNav">
</form> <ul class="navbar-nav me-auto">
<nav class="nav"> <li class="nav-item"><a class="nav-link{{if eq .Nav "calendar"}} active{{end}}" href="/calendar">日历</a></li>
<a href="/calendar" class="{{if eq .Nav "calendar"}}on{{end}}">日历</a> <li class="nav-item"><a class="nav-link{{if eq .Nav "customers"}} active{{end}}" href="/customers">客户</a></li>
<a href="/customers" class="{{if eq .Nav "customers"}}on{{end}}">客户</a> <li class="nav-item"><a class="nav-link{{if eq .Nav "subscriptions"}} active{{end}}" href="/subscriptions">订阅</a></li>
<a href="/subscriptions" class="{{if eq .Nav "subscriptions"}}on{{end}}">订阅</a> <li class="nav-item"><a class="nav-link{{if eq .Nav "packages"}} active{{end}}" href="/packages">套餐</a></li>
<a href="/packages" class="{{if eq .Nav "packages"}}on{{end}}">套餐</a> </ul>
<a href="/screen" target="_blank" rel="noopener" class="nav-screen">大屏 ↗</a> <form class="nav-search" role="search" action="/customers" method="get">
</nav> <input class="form-control form-control-sm" type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" data-search>
<div class="topbar-right"> <kbd aria-hidden="true">/</kbd>
<a class="pill pill-due" href="/subscriptions?state=due_today" title="今天到期的订阅">今日到期 <b>{{.DueToday}}</b></a> </form>
<a class="pill pill-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a> <div class="nav-side">
<a class="pill" href="/subscriptions?state=due7" title="7 天内到期">7 天内 <b>{{.Due7}}</b></a> <a class="badge-due" href="/subscriptions?state=due_today" title="今天到期">今日到期 <b>{{.DueToday}}</b></a>
<button class="theme-toggle" type="button" data-theme-toggle aria-label="切换亮色/暗色" title="切换亮色/暗色"> <a class="badge-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a>
<span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span> <a class="badge-muted" href="/subscriptions?state=due7" title="7 天内到期">7 天内 <b>{{.Due7}}</b></a>
</button> <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="me"> <div class="dropdown">
<a href="/settings">{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}</a> <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>
<form method="post" action="/logout"><button type="submit" class="link-btn">退出</button></form> <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>
</div> </div>
</header> </nav>
{{else}}
<button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" title="切换亮色/暗色">
<span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span>
</button>
{{end}} {{end}}
{{if .Flash}}<div class="flash" data-flash>{{.Flash}}</div>{{end}} {{if .Flash}}<div class="flash" role="status" data-flash>{{.Flash}}</div>{{end}}
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}} {{if .Error}}<div class="notice notice-danger" role="alert">{{.Error}}</div>{{end}}
<main{{if .User}} class="with-top"{{end}}>{{template "content" .}}</main> <main class="{{if .User}}app-main{{else}}auth-main{{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}} {{if .User}}<footer class="app-foot">
<script src="/static/app.js?v={{.Version}}" defer></script> <span>代打台-L <span class="foot-version">v{{.Version}}</span></span>
<span class="foot-by">Powered by Baka春秋</span>
<a href="/logs">操作日志</a>
<a href="/settings">设置</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> </body>
</html>{{end}} </html>{{end}}
+12 -49
View File
@@ -3,9 +3,9 @@
{{define "pager"}} {{define "pager"}}
{{if gt .Pages 1}} {{if gt .Pages 1}}
<nav class="pager"> <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> <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> </nav>
{{end}} {{end}}
{{end}} {{end}}
@@ -38,40 +38,29 @@
<form method="post" action="/subscriptions/{{.ID}}/checkin"> <form method="post" action="/subscriptions/{{.ID}}/checkin">
<input type="hidden" name="date" value="{{.Today}}"> <input type="hidden" name="date" value="{{.Today}}">
<input type="hidden" name="status" value="done"> <input type="hidden" name="status" value="done">
<button class="btn btn-sm">今日打卡</button> <button class="btn btn-primary btn-sm">今日打卡</button>
</form> </form>
{{end}} {{end}}
{{if .Season}} <a class="btn btn-soft btn-sm" href="?extend={{.ID}}#extend">追加时长</a>
{{if eq .SeasonDone ""}}
<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}}
<span class="chip chip-active">赛季已完成 {{.SeasonDone}}</span>
{{end}}
{{else}}
<a class="btn btn-primary btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
{{end}}
{{if eq .Status "active"}} {{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?"> <form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
<button class="btn btn-sm">暂停</button> <button class="btn btn-soft btn-sm">暂停</button>
</form> </form>
{{else if eq .Status "paused"}} {{else if eq .Status "paused"}}
<form method="post" action="/subscriptions/{{.ID}}/resume"> <form method="post" action="/subscriptions/{{.ID}}/resume">
<button class="btn btn-sm">恢复</button> <button class="btn btn-soft btn-sm">恢复</button>
</form> </form>
{{end}} {{end}}
<form method="post" action="/subscriptions/{{.ID}}/close" data-confirm="结束后该订阅不再出现在待打清单,确认结束?"> <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> </form>
{{else}} {{else}}
<form method="post" action="/subscriptions/{{.ID}}/reopen"> <form method="post" action="/subscriptions/{{.ID}}/reopen">
<button class="btn btn-sm">重新启用</button> <button class="btn btn-soft btn-sm">重新启用</button>
</form> </form>
<form method="post" action="/subscriptions/{{.ID}}/delete" data-confirm="将永久删除该订阅及其打卡/追加记录,无法恢复。确定删除?"> <form method="post" action="/subscriptions/{{.ID}}/delete" data-confirm="将永久删除该订阅及其打卡/追加记录,无法恢复。确定删除?">
<input type="hidden" name="confirm" value="delete"> <input type="hidden" name="confirm" value="delete">
<button class="btn btn-sm btn-ghost" style="color:var(--danger)">删除</button> <button class="btn btn-ghost btn-sm text-danger">删除</button>
</form> </form>
{{end}} {{end}}
</div> </div>
@@ -82,12 +71,11 @@
<section class="extend" id="extend" style="--tag:{{.Sub.PackageColor}}"> <section class="extend" id="extend" style="--tag:{{.Sub.PackageColor}}">
<header class="extend-head"> <header class="extend-head">
<div> <div>
<h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加{{if .IsDay}}时长{{else}}次数{{end}}</h3> <h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加时长</h3>
<p class="muted">{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p> <p>{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p>
</div> </div>
<a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a> <a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a>
</header> </header>
{{if .IsDay}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form" <form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form"
data-extend data-base="{{.Base}}" data-old-end="{{.OldEnd}}" data-today="{{.Today}}"> data-extend data-base="{{.Base}}" data-old-end="{{.OldEnd}}" data-today="{{.Today}}">
<input type="hidden" name="back" value="{{.Back}}"> <input type="hidden" name="back" value="{{.Back}}">
@@ -118,33 +106,8 @@
</label> </label>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button> <button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a> <a class="btn btn-soft" href="{{.Back}}">取消</a>
</div> </div>
</form> </form>
{{else}}
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form">
<input type="hidden" name="back" value="{{.Back}}">
<div class="extend-preview">
<div><span>当前剩余</span><b>{{.View.RemainText}}</b></div>
<div><span>已用 / 总数</span><b>{{.Sub.UsedCount}} / {{.Sub.TotalCount}}</b></div>
{{if .Sub.EndDate}}<div><span>有效期至</span><b>{{.Sub.EndDate}}</b></div>{{end}}
</div>
<div class="grid-2">
<label class="field"><span>追加次数</span>
<input type="number" name="add_count" min="1" max="10000" value="{{.DefaultAmount}}" required>
</label>
<label class="field"><span>新的有效期(留空不改)</span>
<input type="date" name="new_end_opt">
</label>
</div>
<label class="field"><span>备注</span>
<input type="text" name="note" maxlength="120" placeholder="例如:加购 10 次副本">
</label>
<div class="actions">
<button type="submit" class="btn btn-primary">确认追加</button>
<a class="btn" href="{{.Back}}">取消</a>
</div>
</form>
{{end}}
</section> </section>
{{end}} {{end}}
@@ -1,15 +0,0 @@
{{define "screenlayout"}}<!doctype html>
<html lang="zh-CN"{{if .Theme}} data-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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?v={{.D.Version}}">
</head>
<body class="screen-body">
{{template "content" .}}
<script src="/static/screen.js?v={{.D.Version}}" defer></script>
</body>
</html>{{end}}
+11 -30
View File
@@ -5,7 +5,7 @@
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}&{{.D.FilterQuery}}" 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> <h1>{{.D.Title}}</h1>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}&{{.D.FilterQuery}}" aria-label="下个月">›</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-sm" href="/calendar?{{.D.FilterQuery}}">回到今天</a> <a class="btn btn-soft btn-sm" href="/calendar?{{.D.FilterQuery}}">回到今天</a>
</div> </div>
<div class="cal-stats"> <div class="cal-stats">
<span><em>{{.D.MonthDue}}</em>本月到期</span> <span><em>{{.D.MonthDue}}</em>本月到期</span>
@@ -42,8 +42,6 @@
{{range .}} {{range .}}
<a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}" <a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&{{$.D.FilterQuery}}" role="gridcell"> href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&{{$.D.FilterQuery}}" role="gridcell">
{{if .HasSeason}}<span class="season-band{{if .SeasonStart}} start{{end}}{{if .SeasonEnd}} end{{end}}" style="--sc:{{.SeasonColor}}" aria-hidden="true"></span>{{end}}
{{if .Strip}}<span class="asg-strip" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
<div class="cell-top"> <div class="cell-top">
<span class="dnum">{{.Day}}</span> <span class="dnum">{{.Day}}</span>
<span class="cell-badges"> <span class="cell-badges">
@@ -83,25 +81,10 @@
<input type="hidden" name="pkg" value="{{.D.PkgID}}"> <input type="hidden" name="pkg" value="{{.D.PkgID}}">
<input type="hidden" name="tag" value="{{.D.Tag}}"> <input type="hidden" name="tag" value="{{.D.Tag}}">
<input type="hidden" name="back" value="{{.D.Back}}"> <input type="hidden" name="back" value="{{.D.Back}}">
<input type="text" name="tail" placeholder="输入手机尾号,回车即可完成" maxlength="8" autocomplete="off" inputmode="numeric" aria-label="手机尾号快速完成"> <input class="form-control form-control-sm" type="text" name="tail" placeholder="输入手机尾号,回车即可完成" maxlength="8" autocomplete="off" inputmode="numeric" aria-label="手机尾号快速完成">
<button class="btn btn-sm btn-primary" type="submit">完成</button> <button class="btn btn-sm btn-primary" type="submit">完成</button>
<button type="button" class="btn btn-sm btn-ghost keypad-toggle" data-keypad-toggle title="切换触控/PC 模式" aria-label="切换触控/PC 模式">⌨</button>
</form> </form>
<p class="muted small quick-hint">唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。</p> <p class="muted small quick-hint">唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。</p>
<div class="keypad" data-keypad role="group" aria-label="数字小键盘" hidden>
<button type="button" class="key" data-key="1">1</button>
<button type="button" class="key" data-key="2">2</button>
<button type="button" class="key" data-key="3">3</button>
<button type="button" class="key" data-key="4">4</button>
<button type="button" class="key" data-key="5">5</button>
<button type="button" class="key" data-key="6">6</button>
<button type="button" class="key" data-key="7">7</button>
<button type="button" class="key" data-key="8">8</button>
<button type="button" class="key" data-key="9">9</button>
<button type="button" class="key key-fn" data-key="back" aria-label="退格">⌫</button>
<button type="button" class="key" data-key="0">0</button>
<button type="button" class="key key-ok" data-key="ok" aria-label="确认完成">✓</button>
</div>
{{if .D.PickTail}} {{if .D.PickTail}}
<section class="picker"> <section class="picker">
@@ -115,7 +98,7 @@
<input type="hidden" name="back" value="{{.D.Back}}"> <input type="hidden" name="back" value="{{.D.Back}}">
<ul class="pick-list"> <ul class="pick-list">
{{range .D.PickRows}} {{range .D.PickRows}}
<li> <li class="list-group-item px-0">
<label> <label>
<input type="checkbox" name="sub_ids" value="{{.ID}}"{{if not .Done}}{{if not .Skipped}} checked{{end}}{{end}}> <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="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
@@ -129,7 +112,7 @@
</ul> </ul>
<div class="actions"> <div class="actions">
<button class="btn btn-primary btn-sm" type="submit">确认完成选中项</button> <button class="btn btn-primary btn-sm" type="submit">确认完成选中项</button>
<a class="btn btn-sm" href="{{.D.Back}}">取消</a> <a class="btn btn-soft btn-sm" href="{{.D.Back}}">取消</a>
</div> </div>
</form> </form>
{{else}} {{else}}
@@ -139,10 +122,10 @@
{{end}} {{end}}
{{if .D.Tasks}} {{if .D.Tasks}}
<ul class="task-list"> <ul class="list-group list-group-flush task-list">
{{range .D.Tasks}} {{range .D.Tasks}}
{{if or .Done .Skipped}} {{if or .Done .Skipped}}
<li class="task task-done" style="--asg:{{.AssigneeHex}}"> <li class="list-group-item task task-done px-0">
<details class="done-fold"> <details class="done-fold">
<summary> <summary>
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span> <span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
@@ -153,7 +136,6 @@
</summary> </summary>
<div class="done-detail"> <div class="done-detail">
<div class="task-meta"> <div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span> <span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}} {{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div> </div>
@@ -173,7 +155,7 @@
<input type="hidden" name="back" value="{{$.D.Back}}"> <input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button> <button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
</form> </form>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&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>
</div> </div>
</details> </details>
</div> </div>
@@ -181,7 +163,7 @@
</details> </details>
</li> </li>
{{else}} {{else}}
<li class="task{{if .Urgent}} urgent{{end}}" style="--asg:{{.AssigneeHex}}"> <li class="list-group-item task{{if .Urgent}} urgent{{end}} px-0">
<div class="task-main"> <div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}"> <a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span> <span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
@@ -189,7 +171,6 @@
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}} {{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
</a> </a>
<div class="task-meta"> <div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span> <span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}} {{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div> </div>
@@ -218,7 +199,7 @@
<input type="hidden" name="back" value="{{$.D.Back}}"> <input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button> <button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
</form> </form>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&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>
</div> </div>
</details> </details>
</div> </div>
@@ -234,7 +215,7 @@
<section class="drawer-block"> <section class="drawer-block">
<h3>这天到期 <em>{{len .D.Due}}</em></h3> <h3>这天到期 <em>{{len .D.Due}}</em></h3>
{{if .D.Due}} {{if .D.Due}}
<ul class="due-list"> <ul class="list-group list-group-flush due-list">
{{range .D.Due}} {{range .D.Due}}
<li> <li>
<a class="due-main" href="/customers/{{.CustomerID}}"> <a class="due-main" href="/customers/{{.CustomerID}}">
@@ -242,7 +223,7 @@
<span class="pkg">{{.PackageName}}</span> <span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span> <span class="rem">{{.RemainShort}}</span>
</a> </a>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&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> </li>
{{end}} {{end}}
</ul> </ul>
@@ -3,43 +3,33 @@
<header class="cust-head"> <header class="cust-head">
<div class="cust-id"> <div class="cust-id">
<span class="cust-tail">{{.D.Customer.Tail}}</span> <span class="cust-tail">{{.D.Customer.Tail}}</span>
<div> <div class="cust-id-main">
<h1>{{.D.Customer.Name}}{{if .D.Customer.Archived}} <span class="chip chip-closed">已停用</span>{{end}}</h1> <h1>{{.D.Customer.Name}}{{if .D.Customer.Archived}} <span class="chip chip-closed">已停用</span>{{end}}</h1>
<div class="cust-tags"> <div class="cust-tags">
{{range .D.Customer.TagList}}<span class="tagchip">{{.}}</span>{{end}} {{range .D.Customer.TagList}}<span class="tagchip">{{.}}</span>{{end}}
</div> </div>
</div> </div>
</div> </div>
<div class="page-actions">
<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"> <dl class="cust-meta">
{{if .D.Customer.GameName}}<div><dt>游戏</dt><dd>{{.D.Customer.GameName}}</dd></div>{{end}} {{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.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}}
{{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}} {{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}}
{{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}} {{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}}
<div><dt>打手</dt><dd>
<span class="asg{{if eq .D.Customer.AssigneeID 0}} none{{end}}"><i style="background:{{if .D.Customer.AssigneeColor}}{{.D.Customer.AssigneeColor}}{{else}}#8A8F98{{end}}"></i>{{if .D.Customer.AssigneeName}}{{.D.Customer.AssigneeName}}{{else}}未指派{{end}}</span>
<form method="post" action="/customers/{{.D.Customer.ID}}/assignee" class="assign-form">
<select name="assignee_id">
<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-sm" type="submit">保存</button>
</form>
</dd></div>
<div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div> <div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div>
</dl> </dl>
<div class="page-actions">
<a class="btn" href="/customers/{{.D.Customer.ID}}/edit">编辑资料</a>
{{if .D.Customer.Archived}}
<form method="post" action="/customers/{{.D.Customer.ID}}/restore"><button class="btn" 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" type="submit" style="color:var(--danger)">彻底删除</button>
</form>
<a class="btn btn-ghost" href="/customers">返回列表</a>
</div>
</header> </header>
{{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}} {{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}}
@@ -49,9 +39,9 @@
<div class="section-head"> <div class="section-head">
<h2>当月排单</h2> <h2>当月排单</h2>
<div class="minical-nav"> <div class="minical-nav">
<a class="btn btn-sm btn-ghost" href="{{.D.CustCal.Prev}}">‹</a> <a class="btn btn-ghost btn-sm btn-square" href="{{.D.CustCal.Prev}}" aria-label="上个月">‹</a>
<b>{{.D.CustCal.Title}}</b> <b>{{.D.CustCal.Title}}</b>
<a class="btn btn-sm btn-ghost" href="{{.D.CustCal.Next}}">›</a> <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> <span class="muted small">{{.D.CustCal.TaskDays}} 天有单 · {{.D.CustCal.TotalTasks}} 单</span>
</div> </div>
</div> </div>
@@ -86,25 +76,22 @@
{{if .D.Packages}} {{if .D.Packages}}
<form method="post" action="/customers/{{.D.Customer.ID}}/subscriptions" class="inline-form" data-sub-form> <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> <option value="">选择套餐…</option>
{{range .D.Packages}} {{range .D.Packages}}
<option value="{{.ID}}" data-unit="{{if .Season}}season{{else}}{{.Unit}}{{end}}" data-amount="{{.DefaultAmount}}" data-validity="{{.DefaultValidityDays}}"{{if .Archived}} disabled{{end}}{{if .SeasonExpired $.TodayStr}} disabled{{end}}> <option value="{{.ID}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
{{.Name}} · {{.UnitLabel}} {{if .Season}}{{.SeasonStart}}~{{.SeasonEnd}}{{else}}{{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{end}}{{if .SeasonExpired $.TodayStr}}(赛季已结束){{end}}{{if .Archived}}(已归档){{end}} {{.Name}} · {{.UnitLabel}} {{.DefaultAmount}} 天{{if .Archived}}(已归档){{end}}
</option> </option>
{{end}} {{end}}
</select> </select>
<label class="inline-field" data-start-wrap>开始日 <label class="inline-field" data-start-wrap>开始日
<input type="date" name="start_date" value="{{.TodayStr}}" required> <input class="form-control form-control-sm" type="date" name="start_date" value="{{.TodayStr}}" required>
</label> </label>
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}} <label class="inline-field" data-days-wrap>
天数 <input 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">
</label> </label>
<label class="inline-field" data-end-wrap> <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> </label>
<button class="btn btn-primary" type="submit">开通订阅</button> <button class="btn btn-primary" type="submit">开通订阅</button>
</form> </form>
@@ -147,9 +134,9 @@
</div> </div>
{{if .D.Adjustments}} {{if .D.Adjustments}}
<div class="table-wrap"> <div class="table-wrap">
<table class="table tight"> <table class="table tight table-adjust">
<thead> <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> </thead>
<tbody> <tbody>
{{range .D.Adjustments}} {{range .D.Adjustments}}
@@ -160,9 +147,7 @@
<td class="mono small">{{if .BaseDate}}{{.BaseDate}}{{else}}—{{end}}</td> <td class="mono small">{{if .BaseDate}}{{.BaseDate}}{{else}}—{{end}}</td>
<td class="mono small">{{if .OldEndDate}}{{.OldEndDate}} → {{end}}{{if .NewEndDate}}{{.NewEndDate}}{{else}}—{{end}}</td> <td class="mono small">{{if .OldEndDate}}{{.OldEndDate}} → {{end}}{{if .NewEndDate}}{{.NewEndDate}}{{else}}—{{end}}</td>
<td class="mono"> <td class="mono">
{{if gt .DaysAdded 0}}<span class="plus">+{{.DaysAdded}} 天</span>{{end}} {{if gt .DaysAdded 0}}<span class="plus">+{{.DaysAdded}} 天</span>{{else}}—{{end}}
{{if gt .CountAdded 0}}<span class="plus">+{{.CountAdded}} 次</span>{{end}}
{{if and (le .DaysAdded 0) (le .CountAdded 0)}}—{{end}}
</td> </td>
<td class="small">{{if .Note}}{{.Note}}{{else}}—{{end}}</td> <td class="small">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
<td class="small">{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}</td> <td class="small">{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}</td>
+10 -20
View File
@@ -3,7 +3,7 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>{{if .D.IsNew}}新建客户{{else}}编辑客户{{end}}</h1> <h1>{{if .D.IsNew}}新建客户{{else}}编辑客户{{end}}</h1>
<p class="muted">手机尾号 + 客户名 唯一;尾号是日常查询的主键。</p> <p>手机尾号 + 客户名 唯一;尾号是日常查询的主键。</p>
</div> </div>
</header> </header>
@@ -11,47 +11,37 @@
action="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}/edit{{end}}"> action="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}/edit{{end}}">
<div class="grid-2"> <div class="grid-2">
<label class="field"><span>手机尾号 <i>*</i></span> <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>
<label class="field"><span>客户名 <i>*</i></span> <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> </label>
</div> </div>
<div class="grid-2"> <div class="grid-2">
<label class="field"><span>游戏名</span> <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>
<label class="field"><span>区服 / UID</span> <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> </label>
</div> </div>
<div class="grid-2"> <div class="grid-2">
<label class="field"><span>联系方式</span> <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>
<label class="field"><span>归属打手</span> <label class="field"><span>来源渠道</span>
<select name="assignee_id"> <input type="text" name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
<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 class="muted">该客户之后的所有单子都记在这个打手名下</small>
</label>
<label class="field"><span>来源渠道</span>
<input name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
</label> </label>
</div> </div>
<label class="field"><span>标签</span> <label class="field"><span>标签</span>
<input name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)"> <input type="text" name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)">
</label> </label>
<label class="field"><span>备注</span> <label class="field"><span>备注</span>
<textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea> <textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea>
</label> </label>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" type="submit">保存</button> <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> </div>
</form> </form>
</div> </div>
+8 -10
View File
@@ -3,7 +3,7 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>客户</h1> <h1>客户</h1>
<p class="muted">共 {{.D.Total}} 位 · 输入手机尾号可快速定位</p> <p>共 {{.D.Total}} 位 · 输入手机尾号可快速定位</p>
</div> </div>
<div class="page-actions"> <div class="page-actions">
{{if .D.ShowArchived}} {{if .D.ShowArchived}}
@@ -16,16 +16,16 @@
</header> </header>
<form class="filterbar" method="get" action="/customers"> <form class="filterbar" method="get" action="/customers">
<input type="search" name="q" value="{{.D.Q}}" placeholder="手机尾号 / 姓名 / 游戏名 / 联系方式" autocomplete="off"> <input type="search" name="q" value="{{.D.Q}}" placeholder="手机尾号 / 姓名 / 游戏名 / 联系方式" autocomplete="off" aria-label="搜索客户">
<button class="btn" type="submit">搜索</button> <button class="btn btn-soft" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/customers">清除</a>{{end}} {{if .D.Q}}<a class="btn btn-ghost" href="/customers">清除</a>{{end}}
</form> </form>
{{if .D.List}} {{if .D.List}}
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table table-customers">
<thead> <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> </thead>
<tbody> <tbody>
{{range .D.List}} {{range .D.List}}
@@ -36,11 +36,9 @@
{{if .GameName}}<div class="muted small">{{.GameName}}{{if .GameServer}} · {{.GameServer}}{{end}}</div>{{end}} {{if .GameName}}<div class="muted small">{{.GameName}}{{if .GameServer}} · {{.GameServer}}{{end}}</div>{{end}}
</td> </td>
<td class="muted small">{{if .Contact}}{{.Contact}}{{else}}—{{end}}</td> <td class="muted small">{{if .Contact}}{{.Contact}}{{else}}—{{end}}</td>
<td> <td><span class="summary due-{{.Level}}">{{.Summary}}</span></td>
<span class="summary due-{{.Level}}">{{.Summary}}</span>
</td>
<td>{{range .TagList}}<span class="tagchip">{{.}}</span>{{end}}</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> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -52,7 +50,7 @@
<div class="empty-mark">票</div> <div class="empty-mark">票</div>
{{if .D.Q}} {{if .D.Q}}
<p>没有匹配「{{.D.Q}}」的客户。</p> <p>没有匹配「{{.D.Q}}」的客户。</p>
<a class="btn" href="/customers">看全部客户</a> <a class="btn btn-soft" href="/customers">看全部客户</a>
{{else}} {{else}}
<p>还没有客户。先建一位,再给他开套餐。</p> <p>还没有客户。先建一位,再给他开套餐。</p>
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a> <a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
+7 -6
View File
@@ -1,20 +1,21 @@
{{define "content"}} {{define "content"}}
<div class="auth"> <div class="auth">
<button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" aria-pressed="false"><span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span></button>
<div class="auth-card"> <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>代打台-L</span></div>
<h1>登录</h1> <h1>登录</h1>
<p class="muted">所有账户均为管理员,任何操作都会留痕。</p> <p>只有一个管理员账户,任何操作都会留痕。</p>
<form method="post" action="/login" class="form"> <form method="post" action="/login" class="stack">
<input type="hidden" name="next" value="{{.Next}}"> <input type="hidden" name="next" value="{{.Next}}">
<label class="field"><span>用户名</span> <label class="field"><span>用户名</span>
<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>
<label class="field"><span>密码</span> <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> </label>
<button type="submit" class="btn btn-primary btn-block">登录</button> <button type="submit" class="btn btn-primary btn-block">登录</button>
</form> </form>
<p class="auth-foot muted">代打台 · 单文件部署 · 数据只在本机</p> <p class="auth-foot">代打台-L · 单文件部署 · 数据只在本机</p>
</div> </div>
</div> </div>
{{end}} {{end}}
+5 -5
View File
@@ -3,20 +3,20 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>操作日志</h1> <h1>操作日志</h1>
<p class="muted">共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里</p> <p>共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里</p>
</div> </div>
</header> </header>
<form class="filterbar" method="get" action="/logs"> <form class="filterbar" method="get" action="/logs">
<input type="search" name="q" value="{{.D.Q}}" placeholder="搜索摘要 / 动作 / 操作人" autocomplete="off"> <input type="search" name="q" value="{{.D.Q}}" placeholder="搜索摘要 / 动作 / 操作人" autocomplete="off" aria-label="搜索日志">
<button class="btn" type="submit">搜索</button> <button class="btn btn-soft" type="submit">搜索</button>
{{if .D.Q}}<a class="btn btn-ghost" href="/logs">清除</a>{{end}} {{if .D.Q}}<a class="btn btn-ghost" href="/logs">清除</a>{{end}}
</form> </form>
{{if .D.List}} {{if .D.List}}
<div class="table-wrap"> <div class="table-wrap">
<table class="table tight"> <table class="table tight table-logs">
<thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>摘要</th><th>IP</th></tr></thead> <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> <tbody>
{{range .D.List}} {{range .D.List}}
<tr> <tr>
@@ -3,64 +3,55 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>{{.D.Package.Name}}</h1> <h1>{{.D.Package.Name}}</h1>
<p class="muted"> <p>
{{if eq .D.Package.Unit "day"}}按日{{else}}按次{{end}} · {{.D.Package.UnitLabel}} · {{.D.Package.AmountText}}
默认 {{.D.Package.DefaultAmount}}{{if eq .D.Package.Unit "day"}} 天{{else}} 次{{end}} · {{if .D.Package.Tag}} · 标签「{{.D.Package.Tag}}」{{end}}{{if .D.Package.CalendarSeparate}} · 独立日历{{end}}
{{if .D.Package.Tag}}标签「{{.D.Package.Tag}}」 · {{end}}{{if .D.Package.CalendarSeparate}}独立日历 · {{end}}
{{if .D.Package.Archived}}<span class="chip chip-closed">已归档</span>{{end}} {{if .D.Package.Archived}}<span class="chip chip-closed">已归档</span>{{end}}
</p> </p>
</div> </div>
<div class="page-actions"> <div class="page-actions">
<a class="btn btn-ghost" href="/packages">返回套餐</a> <a class="btn btn-ghost" href="/packages">返回套餐</a>
<a class="btn" href="/packages/{{.D.Package.ID}}/edit">编辑套餐</a> <a class="btn btn-soft" href="/packages/{{.D.Package.ID}}/edit">编辑套餐</a>
</div> </div>
</header> </header>
{{if .D.Package.Note}}<div class="notice note">{{.D.Package.Note}}</div>{{end}} {{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"> <form method="post" action="/packages/{{.D.Package.ID}}/subscribe" class="section card">
<h2>批量开通</h2> <h2>批量开通</h2>
<p class="muted small">勾选客户后一次开通;已经是该套餐的客户会自动跳过。开始日期默认今天,时长/次数默认取套餐设置,可改。</p> <p class="muted small">勾选客户后一次开通;已经是该套餐的客户会自动跳过。开始日期默认今天,天数默认取套餐设置,可改。</p>
<div class="grid-3"> <div class="grid-auto">
<label class="field"><span>开始日期</span> <label class="field"><span>开始日期</span>
<input type="date" name="start_date" value="{{.D.Today}}" required> <input type="date" name="start_date" value="{{.D.Today}}" required>
</label> </label>
{{if eq .D.Package.Unit "day"}}
<label class="field"><span>天数</span> <label class="field"><span>天数</span>
<input type="number" name="days" min="1" max="3650" value="{{.D.Package.DefaultAmount}}"> <input type="number" name="days" min="1" max="3650" value="{{.D.Package.DefaultAmount}}">
</label> </label>
{{else}}
<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>
{{end}}
</div>
<div class="filterbar">
<input type="search" name="q" value="{{.D.Q}}" placeholder="按尾号 / 姓名筛选客户">
<button class="btn" type="submit" formmethod="get" formaction="/packages/{{.D.Package.ID}}">筛选</button>
<span class="muted small">共 {{len .D.Customers}} 位客户</span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table tight"> <table class="table tight">
<thead> <thead>
<tr> <tr>
<th style="width:44px"><input type="checkbox" data-select-all aria-label="全选"></th> <th style="width:44px"><input type="checkbox" data-select-all aria-label="全选可开通客户"></th>
<th>尾号</th><th>客户</th><th>当前订阅</th><th>状态</th> <th scope="col">尾号</th><th scope="col">客户</th><th scope="col">当前订阅</th><th scope="col">状态</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .D.Customers}} {{range .D.Customers}}
{{$subscribed := index $.D.Subscribed .ID}} {{$subscribed := index $.D.Subscribed .ID}}
<tr{{if $subscribed}} class="archived"{{end}}> <tr{{if $subscribed}} class="archived"{{end}}>
<td><input type="checkbox" name="customer_ids" value="{{.ID}}"{{if $subscribed}} disabled{{else}} checked{{end}}></td> <td>{{if $subscribed}}<span class="muted small" title="已是此套餐">—</span>{{else}}<input type="checkbox" name="customer_ids" value="{{.ID}}" aria-label="选择 {{.Name}}" checked>{{end}}</td>
<td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td> <td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td>
<td>{{.Name}}{{if .AssigneeName}} <span class="asg"><i style="background:{{.AssigneeColor}}"></i>{{.AssigneeName}}</span>{{end}}</td> <td>{{.Name}}</td>
<td class="summary due-{{.Level}}">{{.Summary}}</td> <td class="summary due-{{.Level}}">{{.Summary}}</td>
<td>{{if $subscribed}}<span class="chip chip-closed">已是此套餐</span>{{else}}<span class="chip chip-plain">可开通</span>{{end}}</td> <td>{{if $subscribed}}<span class="chip chip-closed">已是此套餐</span>{{else}}<span class="chip chip-plain">可开通</span>{{end}}</td>
</tr> </tr>
+8 -31
View File
@@ -3,43 +3,21 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1> <h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1>
<p class="muted">按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数。</p> <p>按日套餐用起止日期计算剩余天数;给客户开通时选择开始日与天数即可。</p>
</div> </div>
</header> </header>
<form method="post" class="form card" data-package-form <form method="post" class="form card" data-package-form
action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}"> action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}">
<label class="field"><span>套餐名称 <i>*</i></span> <label class="field"><span>套餐名称 <i>*</i></span>
<input name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 赛季深渊 / 10 次副本"> <input type="text" name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 深渊日常">
</label> </label>
<fieldset class="field">
<span>计费方式 <i>*</i></span>
<div class="radios">
<label><input type="radio" name="unit" value="day"{{if and (eq .D.Package.Unit "day") (not .D.Package.Season)}} checked{{end}}>
<b>按日</b><small>有开始日与结束日,每天一条待打任务,可打卡</small></label>
<label><input type="radio" name="unit" value="count"{{if and (eq .D.Package.Unit "count") (not .D.Package.Season)}} 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>
</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="grid-2" data-validity-wrap hidden>
<label class="field"><span>按次默认有效期(天,0=不限)</span>
<input type="number" name="default_validity_days" min="0" max="3650" value="{{.D.Package.DefaultValidityDays}}">
</label>
</div>
<div class="grid-2"> <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"> <div class="input-suffix">
<input type="number" name="default_amount" min="1" max="10000" value="{{.D.Package.DefaultAmount}}" required> <input type="number" name="default_amount" min="1" max="10000" value="{{.D.Package.DefaultAmount}}" required>
<b data-unit-label>{{if eq .D.Package.Unit "count"}}次{{else}}天{{end}}</b> <b data-unit-label>天</b>
</div> </div>
</label> </label>
<label class="field"><span>参考价(元,可空)</span> <label class="field"><span>参考价(元,可空)</span>
@@ -47,16 +25,15 @@
</label> </label>
</div> </div>
<div class="grid-2"> <div class="grid-3">
<label class="field"><span>日历色签</span> <label class="field"><span>日历色签</span>
<input type="color" name="color" value="{{.D.Package.Color}}"> <input type="color" name="color" value="{{.D.Package.Color}}">
</label> </label>
<label class="field"><span>标签(可选)</span> <label class="field"><span>标签(可选)</span>
<input type="text" name="tag" value="{{.D.Package.Tag}}" maxlength="20" list="pkg-tags" placeholder="例如:包月活动托"> <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> <datalist id="pkg-tags">{{range .D.Tags}}<option value="{{.}}"></option>{{end}}</datalist>
<small class="muted">相同标签的套餐在日历里会合并成同一个页签</small> <small>相同标签的套餐在日历里会合并成同一个页签</small>
</label> </label>
<label class="field"><span>排序(小的靠前)</span> <label class="field"><span>排序(小的靠前)</span>
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}"> <input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
</label> </label>
@@ -64,7 +41,7 @@
<label class="check-line"> <label class="check-line">
<input type="checkbox" name="calendar_separate" value="1"{{if .D.Package.CalendarSeparate}} checked{{end}}> <input type="checkbox" name="calendar_separate" value="1"{{if .D.Package.CalendarSeparate}} checked{{end}}>
<span>在日历中独立显示<small>勾选后工作台和大屏的日历会为该套餐单独开一个标签页,便于把“每日任务”和其他委托分开看</small></span> <span>在日历中独立显示<small>勾选后会单独开一个标签页,和其他日常任务分开看</small></span>
</label> </label>
<label class="field"><span>说明</span> <label class="field"><span>说明</span>
@@ -73,7 +50,7 @@
<div class="actions"> <div class="actions">
<button class="btn btn-primary" type="submit">保存</button> <button class="btn btn-primary" type="submit">保存</button>
<a class="btn" href="/packages">取消</a> <a class="btn btn-soft" href="/packages">取消</a>
</div> </div>
</form> </form>
</div> </div>
+7 -7
View File
@@ -3,16 +3,16 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>套餐</h1> <h1>套餐</h1>
<p class="muted">新增套餐只需加一行配置,不会影响任何已有客户与订阅</p> <p>新增套餐只需加一行配置,不会影响任何已有客户与订阅</p>
</div> </div>
<div class="page-actions"><a class="btn btn-primary" href="/packages/new">+ 新增套餐</a></div> <div class="page-actions"><a class="btn btn-primary" href="/packages/new">+ 新增套餐</a></div>
</header> </header>
{{if .D.List}} {{if .D.List}}
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table table-packages">
<thead> <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> </thead>
<tbody> <tbody>
{{range .D.List}} {{range .D.List}}
@@ -24,15 +24,15 @@
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}} {{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
</td> </td>
<td>{{.UnitLabel}}</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">{{if .HasPrice}}¥{{money .PriceCents}}{{else}}—{{end}}</td>
<td class="mono">{{.ActiveCount}} / {{.SubCount}}</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>{{if .Archived}}<span class="chip chip-closed">已归档</span>{{else}}<span class="chip chip-active">启用中</span>{{end}}</td>
<td class="actions-cell"> <td class="actions-cell">
<a class="btn btn-sm" href="/packages/{{.ID}}">详情/批量</a> <a class="btn btn-soft btn-sm" href="/packages/{{.ID}}">详情/批量</a>
<a class="btn btn-sm btn-ghost" href="/packages/{{.ID}}/edit">编辑</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}}> <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> </form>
</td> </td>
</tr> </tr>
-157
View File
@@ -1,157 +0,0 @@
{{define "content"}}
<div class="scr" data-pkg="{{.D.PkgID}}" data-heat="{{.D.HeatMode}}" data-api="/api/screen/day">
<header class="scr-head">
<div class="scr-brand"><span class="mark">代</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="切换亮色/暗色">
<span class="ic-moon">☾</span><span class="ic-sun">☀</span>
</button>
</div>
<div class="scr-clock"><b data-clock>--:--:--</b><span data-date></span></div>
<div class="scr-links">每 {{.D.Refresh}} 秒刷新 · <a href="/calendar">进入工作台</a></div>
</header>
<section class="scr-kpis">
<div class="kpi">
<span>客户</span><b>{{.D.Stats.Customers}}</b><i>位</i>
</div>
<div class="kpi">
<span>活跃订阅</span><b>{{.D.Stats.ActiveSubs}}</b><i>条</i>
</div>
<div class="kpi kpi-hot">
<span>今日到期</span><b>{{.D.Stats.DueToday}}</b><i>条</i>
</div>
<div class="kpi kpi-warm">
<span>7 天内到期</span><b>{{.D.Stats.Due7}}</b><i>条</i>
</div>
</section>
<section class="scr-main">
<div class="panel panel-cal">
<div class="panel-head">
<h2>{{if eq .D.HeatMode "due"}}到期热力{{else}}每日待完成{{end}}</h2>
<span>{{.D.MonthTitle}} · 本月合计 {{.D.HeatTotal}} 单</span>
</div>
<div class="heat-grid">
<div class="heat-dow">{{range .D.Weekdays}}<span>{{.}}</span>{{end}}</div>
{{range .D.Weeks}}
<div class="heat-row">
{{range .}}
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsPast}} past{{end}}"
data-date="{{.Date}}" title="{{.Date}}">
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
<span>{{.Day}}</span>
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
{{if .Count}}<b>{{.Done}}/{{.Count}}</b>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="heat-legend">
<span>{{if eq .D.HeatMode "due"}}到期数{{else}}待完成单量{{end}}</span>
<i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
<em class="legend-hint">悬停格子看当日明细</em>
</div>
</div>
<div class="panel panel-top">
<div class="panel-head"><h2>即将到期 TOP 10</h2><span>按剩余量排序</span></div>
{{if .D.Top}}
<ol class="top-list">
{{range $i, $s := .D.Top}}
<li class="top-item lv-{{$s.Eval.Level}}">
<span class="rank">{{add $i 1}}</span>
<span class="top-tail">{{$s.CustomerTail}}</span>
<span class="top-name">{{$s.CustomerName}}</span>
<span class="top-pkg" style="--tag:{{$s.PackageColor}}">{{$s.PackageName}}</span>
<span class="top-remain">{{$s.RemainShort}}</span>
<span class="top-bar"><i style="width:{{$s.Progress}}%"></i></span>
</li>
{{end}}
</ol>
{{else}}
<p class="scr-empty">暂无订阅数据</p>
{{end}}
</div>
</section>
<section class="scr-bottom">
<div class="panel">
<div class="panel-head">
<h2>今日到期</h2>
<span>{{len .D.DueToday}} 条</span>
</div>
{{if .D.DueToday}}
<ul class="today-list">
{{range .D.DueToday}}
<li class="lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有到期</p>
{{end}}
</div>
<div class="panel">
<div class="panel-head">
<h2>今日待打</h2>
<span>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成{{if gt .D.TodayUrgent 0}} · 加急 {{.D.TodayUrgent}}{{end}}</span>
</div>
{{if .D.TodayList}}
<ul class="today-list">
{{range .D.TodayList}}
<li class="{{if .Urgent}}urgent{{end}} lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
<span class="rem">{{.RemainShort}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有待打任务</p>
{{end}}
</div>
</section>
<section class="scr-recent">
<span class="recent-label">最近追加</span>
{{if .D.Recent}}
<div class="recent-track">
{{range .D.Recent}}
<span class="recent-item"><b>{{.CustomerTail}}</b>{{.CustomerName}} · {{.PackageName}}
{{if gt .DaysAdded 0}}<em>+{{.DaysAdded}} 天</em>{{end}}
{{if gt .CountAdded 0}}<em>+{{.CountAdded}} 次</em>{{end}}
</span>
{{end}}
</div>
{{else}}
<span class="scr-empty-inline">还没有追加记录</span>
{{end}}
</section>
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 全屏</footer>
<div class="day-pop" id="day-pop" hidden></div>
</div>
{{end}}
+9 -62
View File
@@ -3,73 +3,21 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>设置</h1> <h1>设置</h1>
<p class="muted">预警天数、备份、账户与数据导出</p> <p>预警天数、备份与数据导出</p>
</div> </div>
</header> </header>
<section class="section card"> <section class="section card">
<h2>基础设置</h2> <h2>基础设置</h2>
<form method="post" action="/settings" class="form"> <form method="post" action="/settings" class="form">
<div class="grid-2"> <label class="field field-narrow"><span>到期预警(天)</span>
<label class="field"><span>到期预警(天)</span> <input type="number" name="warn_days" min="0" max="60" value="{{.D.WarnDays}}">
<input type="number" name="warn_days" min="0" max="60" value="{{.D.WarnDays}}"> </label>
</label>
<label class="field"><span>大屏自动刷新(秒)</span>
<input type="number" name="screen_refresh" min="5" max="600" value="{{.D.ScreenRefresh}}">
</label>
</div>
<p class="muted small">监听地址、端口、数据库、备份、日志、时区等部署参数在 <code>daili.ini</code> 里配置,改完重启生效。</p> <p class="muted small">监听地址、端口、数据库、备份、日志、时区等部署参数在 <code>daili.ini</code> 里配置,改完重启生效。</p>
<div class="actions"><button class="btn btn-primary" type="submit">保存设置</button></div> <div class="actions"><button class="btn btn-primary" type="submit">保存设置</button></div>
</form> </form>
</section> </section>
<section class="section card">
<h2>管理员</h2>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。每个管理员的颜色用于日历左侧竖条和待办标识。</p>
<p class="muted small">{{if eq .User.ID 1}}你是初始管理员,可以点「编辑」修改其他管理员的用户名、昵称和密码。{{else}}只有初始管理员(首个账号)可以修改其他管理员的资料。{{end}}</p>
<div class="table-wrap">
<table class="table tight">
<thead><tr><th>用户名</th><th>显示名称</th><th>打手颜色</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></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="打手颜色">
<button class="btn btn-sm btn-ghost" type="submit">保存</button>
</form>
</td>
<td>{{if .IsActive}}<span class="chip chip-active">启用</span>{{else}}<span class="chip chip-closed">停用</span>{{end}}</td>
<td class="mono small">{{.CreatedAt}}</td>
<td class="mono small">{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}}</td>
<td class="actions-cell">
{{if eq .ID $me.ID}}
<span class="muted small">当前账户</span>
{{else}}
{{if eq $me.ID 1}}
<a class="btn btn-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>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</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="password" name="password" required minlength="6" autocomplete="new-password"></label>
<button class="btn btn-primary" type="submit">新增管理员</button>
</form>
</section>
<section class="section card"> <section class="section card">
<h2>修改我的密码</h2> <h2>修改我的密码</h2>
<form method="post" action="/settings/password" class="form"> <form method="post" action="/settings/password" class="form">
@@ -88,16 +36,15 @@
<div><dt>配置文件</dt><dd class="mono small">{{.D.ConfigPath}} <span class="muted">(修改后重启生效)</span></dd></div> <div><dt>配置文件</dt><dd class="mono small">{{.D.ConfigPath}} <span class="muted">(修改后重启生效)</span></dd></div>
<div><dt>数据库</dt><dd class="mono small">{{.D.DBPath}}</dd></div> <div><dt>数据库</dt><dd class="mono small">{{.D.DBPath}}</dd></div>
<div><dt>备份</dt><dd class="mono small">{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}</dd></div> <div><dt>备份</dt><dd class="mono small">{{.D.BackupDir}} · 保留 {{.D.BackupKeep}} 份 · {{if .D.BackupEnabled}}自动备份已开启{{else}}自动备份已关闭{{end}}</dd></div>
<div><dt>大屏</dt><dd class="mono small">/screen {{if .D.ScreenPublic}}免登录{{else}}需登录{{end}}</dd></div> <div><dt>局域网地址</dt><dd class="mono small">http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}</dd></div>
<div><dt>局域网地址</dt><dd class="mono small">http://{{if .D.Addrs}}{{index .D.Addrs 0}}{{else}}127.0.0.1{{end}}:{{.D.Port}}/screen</dd></div>
<div><dt>已运行</dt><dd class="mono">{{.D.Uptime}}</dd></div> <div><dt>已运行</dt><dd class="mono">{{.D.Uptime}}</dd></div>
<div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div> <div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div>
</dl> </dl>
<div class="actions wrap"> <div class="actions wrap">
<form method="post" action="/settings/backup"><button class="btn" type="submit">立即备份</button></form> <form method="post" action="/settings/backup"><button class="btn btn-soft" type="submit">立即备份</button></form>
<a class="btn" href="/export/customers.csv">导出客户 CSV</a> <a class="btn btn-soft" href="/export/customers.csv">导出客户 CSV</a>
<a class="btn" href="/export/subscriptions.csv">导出订阅 CSV</a> <a class="btn btn-soft" href="/export/subscriptions.csv">导出订阅 CSV</a>
<a class="btn" href="/export/logs.csv">导出日志 CSV</a> <a class="btn btn-soft" href="/export/logs.csv">导出日志 CSV</a>
</div> </div>
{{if .D.Backups}} {{if .D.Backups}}
<details class="backups"> <details class="backups">
+11 -11
View File
@@ -1,25 +1,25 @@
{{define "content"}} {{define "content"}}
<div class="auth"> <div class="auth">
<button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" aria-pressed="false"><span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span></button>
<div class="auth-card"> <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>代打台-L</span></div>
<h1>创建第一个管理员</h1> <h1>创建管理员</h1>
<p class="muted">数据库里还没有账户。设置好账号后即可开始录入套餐与客户。</p> <p>数据库里还没有账户。创建后不能再添加其他管理员。</p>
<form method="post" action="/setup" class="form"> <form method="post" action="/setup" class="stack">
<label class="field"><span>用户名</span> <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>
<label class="field"><span>显示名称</span> <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> <label class="field"><span>密码(至少 6 位)</span>
<label class="field"><span>密码(至少 6 位)</span> <input class="form-control" id="password" type="password" name="password" required minlength="6" autocomplete="new-password">
<input type="password" name="password" required minlength="6" autocomplete="new-password">
</label> </label>
<label class="field"><span>再输一次密码</span> <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> </label>
<button type="submit" class="btn btn-primary btn-block">创建并登录</button> <button type="submit" class="btn btn-primary btn-block">创建并登录</button>
</form> </form>
<p class="auth-foot muted">忘记密码只能手动改数据库,请妥善保管。</p> <p class="auth-foot">忘记密码只能手动改数据库,请妥善保管。</p>
</div> </div>
</div> </div>
{{end}} {{end}}
+13 -13
View File
@@ -3,11 +3,11 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>订阅</h1> <h1>订阅</h1>
<p class="muted">共 {{.D.Total}} 条 · 剩余时长各自独立计算</p> <p>共 {{.D.Total}} 条 · 剩余时长各自独立计算</p>
</div> </div>
</header> </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=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=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> <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=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=closed" class="{{if eq .D.State "closed"}}on{{end}}">已结束</a>
<a href="/subscriptions?state=all" class="{{if eq .D.State "all"}}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"> <form class="filterbar" method="get" action="/subscriptions">
<input type="hidden" name="state" value="{{.D.State}}"> <input type="hidden" name="state" value="{{.D.State}}">
<input type="search" name="q" value="{{.D.Q}}" placeholder="尾号 / 姓名 / 套餐" autocomplete="off"> <input type="search" name="q" value="{{.D.Q}}" placeholder="尾号 / 姓名 / 套餐" autocomplete="off" aria-label="搜索订阅">
<select name="package_id"> <select name="package_id" aria-label="按套餐筛选">
<option value="0">全部套餐</option> <option value="0">全部套餐</option>
{{range .D.Packages}} {{range .D.Packages}}
<option value="{{.ID}}"{{if eq $.D.PackageID .ID}} selected{{end}}>{{.Name}}</option> <option value="{{.ID}}"{{if eq $.D.PackageID .ID}} selected{{end}}>{{.Name}}</option>
{{end}} {{end}}
</select> </select>
<button class="btn" type="submit">筛选</button> <button class="btn btn-soft" type="submit">筛选</button>
</form> </form>
{{if .D.List}} {{if .D.List}}
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table table-subs">
<thead> <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> </thead>
<tbody> <tbody>
{{range .D.List}} {{range .D.List}}
@@ -49,14 +49,14 @@
<td class="mono small">{{.PeriodText}}</td> <td class="mono small">{{.PeriodText}}</td>
<td class="actions-cell"> <td class="actions-cell">
{{if ne .Status "closed"}} {{if ne .Status "closed"}}
<a class="btn btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a> <a class="btn btn-soft btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>
{{if eq .Status "active"}} {{if eq .Status "active"}}
<form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-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"}} {{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}} {{end}}
{{else}} {{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}} {{end}}
</td> </td>
</tr> </tr>
@@ -69,7 +69,7 @@
<div class="empty"> <div class="empty">
<div class="empty-mark">票</div> <div class="empty-mark">票</div>
<p>这个筛选下没有订阅。</p> <p>这个筛选下没有订阅。</p>
<a class="btn" href="/subscriptions?state=all">看全部</a> <a class="btn btn-soft" href="/subscriptions?state=all">看全部</a>
</div> </div>
{{end}} {{end}}
</div> </div>
@@ -1,39 +0,0 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>编辑管理员</h1>
<p class="muted">修改 {{.D.Target.Username}} 的登录名、昵称,或重置密码。</p>
</div>
<div class="page-actions">
<a class="btn btn-ghost" href="/settings">返回设置</a>
</div>
</header>
<form method="post" action="/settings/users/{{.D.Target.ID}}/edit" class="form card">
<div class="grid-2">
<label class="field"><span>用户名 <i>*</i></span>
<input type="text" name="username" value="{{.D.Target.Username}}" required minlength="3" maxlength="32" autocomplete="off">
</label>
<label class="field"><span>显示名称</span>
<input type="text" name="display_name" value="{{.D.Target.DisplayName}}" maxlength="32" autocomplete="off">
</label>
</div>
<div class="grid-2">
<label class="field"><span>新密码</span>
<input type="password" name="password" minlength="6" autocomplete="new-password" placeholder="留空则不修改">
</label>
<label class="field"><span>再输一次</span>
<input type="password" name="password2" minlength="6" autocomplete="new-password" placeholder="与上面一致">
</label>
</div>
<p class="muted small">重置密码后该账户的登录会话会立即失效,需要用新密码重新登录。当前账户:{{.User.Username}}</p>
<div class="actions">
<button class="btn btn-primary" type="submit">保存修改</button>
<a class="btn btn-ghost" href="/settings">取消</a>
</div>
</form>
</div>
{{end}}
+11 -72
View File
@@ -31,25 +31,6 @@ type SubView struct {
CanCheckinToday bool CanCheckinToday bool
} }
// AssigneeLabel 返回打手显示名;未指派时给灰字提示。
func (v SubView) AssigneeLabel() string {
if v.AssigneeName == "" {
return "未指派"
}
return v.AssigneeName
}
// AssigneeHex 返回打手颜色;未指派用中性灰。
func (v SubView) AssigneeHex() string {
if v.AssigneeColor == "" {
return "#8A8F98"
}
return v.AssigneeColor
}
// AssigneeNone 是否未指派。
func (v SubView) AssigneeNone() bool { return v.AssigneeName == "" }
func stateLabel(state string) (string, string) { func stateLabel(state string) (string, string) {
switch state { switch state {
case domain.StateActive: case domain.StateActive:
@@ -60,8 +41,6 @@ func stateLabel(state string) (string, string) {
return "暂停中", "paused" return "暂停中", "paused"
case domain.StateExpired: case domain.StateExpired:
return "已到期", "expired" return "已到期", "expired"
case domain.StateUsedUp:
return "次数用尽", "usedup"
case domain.StateClosed: case domain.StateClosed:
return "已结束", "closed" return "已结束", "closed"
} }
@@ -76,45 +55,15 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
Eval: e, Eval: e,
StateLabel: label, StateLabel: label,
StateClass: class, StateClass: class,
UnitLabel: "按日",
Today: domain.Date(today), Today: domain.Date(today),
} }
if s.Season { v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
v.UnitLabel = "赛季" v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
if s.SeasonDone != "" { v.PeriodText = s.StartDate + " → " + s.EndDate
v.RemainText = "赛季已完成" total := domain.DayDiff(domain.ParseDateOr(s.StartDate, today), domain.ParseDateOr(s.EndDate, today)) + 1
v.RemainShort = "已完成" if total > 0 {
v.StateLabel = "赛季已完成" v.Progress = clampPct(e.RemainingDays * 100 / total)
v.StateClass = "active"
} else {
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
}
v.PeriodText = s.StartDate + " → " + s.EndDate
return v
}
if s.Unit == domain.UnitDay {
v.UnitLabel = "按日"
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
v.PeriodText = s.StartDate + " → " + s.EndDate
total := domain.DayDiff(domain.ParseDateOr(s.StartDate, today), domain.ParseDateOr(s.EndDate, today)) + 1
if total > 0 {
v.Progress = clampPct(e.RemainingDays * 100 / total)
}
} else {
v.UnitLabel = "按次"
v.RemainText = fmt.Sprintf("已完成 %d / %d 次", s.UsedCount, s.TotalCount)
v.RemainShort = fmt.Sprintf("%d/%d 次", s.UsedCount, s.TotalCount)
v.PeriodText = s.StartDate + " 起"
if s.EndDate != "" {
v.PeriodText += " · 有效期至 " + s.EndDate
} else {
v.PeriodText += " · 无有效期"
}
if s.TotalCount > 0 {
v.Progress = clampPct(s.UsedCount * 100 / s.TotalCount)
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
}
} }
switch e.Level { switch e.Level {
@@ -126,10 +75,8 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
v.DueText = "明天到期" v.DueText = "明天到期"
case e.DaysLeft > 1: case e.DaysLeft > 1:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft) v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
case s.Unit == domain.UnitDay:
v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays)
default: default:
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft) v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays)
} }
case domain.LevelExpired: case domain.LevelExpired:
if s.EndDate != "" { if s.EndDate != "" {
@@ -139,13 +86,10 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
} else { } else {
v.DueText = "已到期" v.DueText = "已到期"
} }
} else {
v.DueText = "次数已用尽"
} }
} }
// 今天能否打卡:进行中且(按日或有剩余次数) // 今天能否打卡:进行中且在有效区间内
v.CanCheckinToday = e.State == domain.StateActive && v.CanCheckinToday = e.State == domain.StateActive
(s.Unit == domain.UnitDay || e.RemainingCount > 0)
return v return v
} }
@@ -169,11 +113,10 @@ func subViews(subs []store.Subscription, today time.Time, warnDays int) []SubVie
// ---------- 追加面板 ---------- // ---------- 追加面板 ----------
// ExtendPanel 是「追加时长/次数」面板数据。 // ExtendPanel 是「追加时长」面板数据。
type ExtendPanel struct { type ExtendPanel struct {
Sub store.Subscription Sub store.Subscription
View SubView View SubView
IsDay bool
Today string Today string
OldEnd string OldEnd string
Base string Base string
@@ -205,7 +148,6 @@ func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time
p := &ExtendPanel{ p := &ExtendPanel{
Sub: *s, Sub: *s,
View: newSubView(*s, today, warnDays), View: newSubView(*s, today, warnDays),
IsDay: s.Unit == domain.UnitDay,
Today: domain.Date(today), Today: domain.Date(today),
Back: back, Back: back,
DefaultAmount: pkg.DefaultAmount, DefaultAmount: pkg.DefaultAmount,
@@ -213,9 +155,6 @@ func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time
if p.DefaultAmount <= 0 { if p.DefaultAmount <= 0 {
p.DefaultAmount = 30 p.DefaultAmount = 30
} }
if s.Unit != domain.UnitDay {
return p
}
oldEnd := domain.ParseDateOr(s.EndDate, today) oldEnd := domain.ParseDateOr(s.EndDate, today)
plan := domain.PlanExtend(oldEnd, oldEnd, today) plan := domain.PlanExtend(oldEnd, oldEnd, today)
suggested := domain.AddDays(plan.Base, p.DefaultAmount-1) suggested := domain.AddDays(plan.Base, p.DefaultAmount-1)
+33 -16
View File
@@ -17,6 +17,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
@@ -26,7 +27,7 @@ import (
"daili/internal/web" "daili/internal/web"
) )
const version = "1.8.2" const version = "1.11.5-L"
func main() { func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")") cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
@@ -41,7 +42,7 @@ func main() {
flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true }) flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true })
log.SetFlags(log.LstdFlags) log.SetFlags(log.LstdFlags)
log.SetPrefix("[代打台] ") log.SetPrefix("[代打台-L] ")
exeDir := executableDir() exeDir := executableDir()
cfgPath := *cfgFlag cfgPath := *cfgFlag
@@ -51,7 +52,7 @@ func main() {
cfg, err := config.Load(cfgPath, exeDir) cfg, err := config.Load(cfgPath, exeDir)
if err != nil { if err != nil {
// 配置写错时不要吞掉,直接指向具体行 // 配置写错时不要吞掉,直接指向具体行
fmt.Fprintf(os.Stderr, "\n[代打台] 配置文件有问题,已停止启动:\n\n%v\n\n", err) fmt.Fprintf(os.Stderr, "\n[代打台-L] 配置文件有问题,已停止启动:\n\n%v\n\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -130,7 +131,6 @@ func main() {
BackupKeep: cfg.BackupKeep, BackupKeep: cfg.BackupKeep,
BackupDir: cfg.BackupDir, BackupDir: cfg.BackupDir,
BackupEnabled: cfg.BackupEnabled, BackupEnabled: cfg.BackupEnabled,
ScreenPublic: cfg.ScreenPublic,
ConfigPath: cfg.Path, ConfigPath: cfg.Path,
Version: version, Version: version,
}) })
@@ -200,7 +200,7 @@ func main() {
} }
func fatal(format string, args ...any) { func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "\n[代打台] "+format+"\n\n", args...) fmt.Fprintf(os.Stderr, "\n[代打台-L] "+format+"\n\n", args...)
os.Exit(1) os.Exit(1)
} }
@@ -239,10 +239,11 @@ func executableDir() string {
func printBanner(cfg *config.Config, dbPath string) string { func printBanner(cfg *config.Config, dbPath string) string {
port := strconv.Itoa(cfg.ListenPort) port := strconv.Itoa(cfg.ListenPort)
const inner = 47
fmt.Println() fmt.Println()
fmt.Println(" ┌───────────────────────────────────────────────┐") fmt.Println(" ┌" + strings.Repeat("─", inner) + "┐")
fmt.Println(" │ 代 打 台 · 代打管理平台 │") fmt.Println(" " + bannerRow("代 打 台 - L · 代打管理平台", inner))
fmt.Println(" └───────────────────────────────────────────────┘") fmt.Println(" └" + strings.Repeat("─", inner) + "┘")
fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path)) fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path))
fmt.Printf(" 数据库 %s\n", dbPath) fmt.Printf(" 数据库 %s\n", dbPath)
fmt.Println(" 访问地址:") fmt.Println(" 访问地址:")
@@ -264,19 +265,35 @@ func printBanner(cfg *config.Config, dbPath string) string {
fmt.Printf(" 地址 %s\n", primary) fmt.Printf(" 地址 %s\n", primary)
} }
if cfg.ScreenPublic {
fmt.Printf(" 大屏 %s/screen(免登录)\n", primary)
} else {
fmt.Printf(" 大屏 %s/screen(需登录)\n", primary)
}
if cfg.PublicBind() && cfg.ScreenPublic {
fmt.Println(" 注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen")
}
fmt.Println(" 按 Ctrl+C 停止服务") fmt.Println(" 按 Ctrl+C 停止服务")
fmt.Println() fmt.Println()
return primary return primary
} }
// bannerRow 把一行标题居中放进方框;中文按两列宽计算,保证不歪。
func bannerRow(text string, inner int) string {
w := displayWidth(text)
if w >= inner {
return "│" + text + "│"
}
left := (inner - w) / 2
right := inner - w - left
return "│" + strings.Repeat(" ", left) + text + strings.Repeat(" ", right) + "│"
}
// displayWidth 估算终端显示宽度:非 ASCII 一律按两列算。
func displayWidth(s string) int {
w := 0
for _, r := range s {
if r < 0x1100 {
w++
continue
}
w += 2
}
return w
}
func openBrowser(url string) { func openBrowser(url string) {
var cmd *exec.Cmd var cmd *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
+126
View File
@@ -0,0 +1,126 @@
# lite 版冒烟:起一个隔离实例,把所有页面都渲染一遍(用 curl.exe,绕开受约束语言模式)
$ErrorActionPreference = 'Stop'
$root = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
if (-not (Test-Path (Join-Path $root 'go.mod'))) { $root = Join-Path $root 'subtrack' }
$port = 17779
$exe = Join-Path $root 'daili-lite-smoke.exe'
$db = Join-Path $root '.gotmp\smoke-lite.db'
$jar = Join-Path $root '.gotmp\cookies.txt'
$base = "http://127.0.0.1:$port"
$resp = @{}
$i = 0
$fail = 0
New-Item -ItemType Directory -Force -Path (Join-Path $root '.gotmp') | Out-Null
foreach ($f in @($db, $jar)) { if (Test-Path $f) { Remove-Item $f -Force } }
$env:GOFLAGS='-mod=mod'; $env:GOPROXY='off'; $env:GOSUMDB='off'
$env:GOCACHE=(Join-Path $root '.gocache'); $env:GOTMPDIR=(Join-Path $root '.gotmp')
Push-Location $root
go build -o $exe .
$buildOk = ($LASTEXITCODE -eq 0)
Pop-Location
if (-not $buildOk) { Write-Output 'BUILD FAILED'; exit 1 }
Write-Output 'build ok'
$logFile = Join-Path $root '.gotmp\smoke-server.log'
$proc = Start-Process -FilePath $exe -ArgumentList @('-addr', "127.0.0.1:$port", '-db', $db, '-no-open', '-no-backup') -PassThru -RedirectStandardOutput $logFile -RedirectStandardError ($logFile + '.err') -WindowStyle Hidden
Start-Sleep -Seconds 2
function Req($name, $path, $method = 'GET', $data = $null) {
$body = Join-Path $root ('.gotmp\resp-{0}.html' -f $global:i)
$global:i = $global:i + 1
$a = @('-s', '-o', $body, '-w', '%{http_code}', '-b', $global:jar, '-c', $global:jar, '-L', '--max-time', '20')
if ($method -eq 'POST') {
# 不要用 -X POST:curl 会在 303 重定向后继续 POST,导致下一跳 405
foreach ($k in $data.Keys) { $a = $a + @('--data-urlencode', ($k + '=' + $data[$k])) }
}
$a = $a + @($global:base + $path)
$code = (& curl.exe @a) -join ''
$text = ''
if (Test-Path $body) { $text = (Get-Content -Raw -Encoding utf8 $body) }
if ($null -eq $text) { $text = '' }
$bad = $text -match '页面渲染失败|模板不存在'
$status = if ($bad) { 'TEMPLATE-ERROR' } else { 'ok' }
if ($bad -or $code -ne '200') { $global:fail = $global:fail + 1 }
Write-Output ('{0,-26} {1} {2} len={3}' -f $name, $code, $status, $text.Length)
$global:resp[$name] = $text
}
function Post($name, $path, $data) { Req $name $path 'POST' $data }
Write-Output '--- 页面渲染 ---'
Req 'GET /login' '/login'
Req 'GET /setup' '/setup'
Post 'POST /setup' '/setup' @{ username = 'admin'; display_name = 'boss'; password = 'daili123'; password2 = 'daili123' }
Req 'GET /calendar' '/calendar'
Req 'GET /settings' '/settings'
Req 'GET /logs' '/logs'
Req 'GET /packages' '/packages'
Req 'GET /packages/new' '/packages/new'
Post 'POST /packages' '/packages' @{ name = 'monthly'; default_amount = '30'; price = '300'; color = '#3B6EA5'; sort_order = '0'; note = 'smoke' }
Req 'GET /packages/1' '/packages/1'
Req 'GET /packages/1/edit' '/packages/1/edit'
Req 'GET /customers' '/customers'
Req 'GET /customers/new' '/customers/new'
Post 'POST /customers' '/customers' @{ tail = '1234'; name = 'laozhang'; game_name = 'wzry'; game_server = 'wx'; contact = 'wx'; source = 'douyin'; tags = 'vip'; note = 'smoke' }
Req 'GET /customers/1' '/customers/1'
Req 'GET /customers/1/edit' '/customers/1/edit'
Post 'POST /sub-create' '/customers/1/subscriptions' @{ package_id = '1'; start_date = '2026-01-01'; days = '30'; end_date = ''; note = 'smoke' }
Req 'GET /subscriptions' '/subscriptions'
Req 'GET /calendar?extend' '/calendar?extend=1'
Req 'GET /calendar?pick' '/calendar?pick=1234'
Post 'POST /checkin' '/subscriptions/1/checkin' @{ date = '2026-01-01'; status = 'done'; note = '' }
Post 'POST /settings' '/settings' @{ warn_days = '5' }
Post 'POST /password' '/settings/password' @{ old_password = 'daili123'; password = 'daili456'; password2 = 'daili456' }
Write-Output '--- 真实数据(今天开通 → 日历应有待打任务)---'
$today = (Get-Date).ToString('yyyy-MM-dd')
Post 'POST /packages2' '/packages' @{ name = 'weekly'; default_amount = '7'; price = '80'; color = '#884422'; sort_order = '0' }
Post 'POST /sub2' '/customers/1/subscriptions' @{ package_id = '2'; start_date = $today; days = '30'; end_date = '' }
Req 'GET /calendar-live' '/calendar'
$live = $resp['GET /calendar-live']
Write-Output '--- 已移除的路由 ---'
foreach ($gone in @('/screen', '/customer', '/api/screen/day')) {
$code = (& curl.exe -s -o NUL -w '%{http_code}' -b $jar --max-time 10 ($base + $gone)) -join ''
$tag = if ($code -eq '404') { 'ok (404)' } else { 'UNEXPECTED'; }
if ($code -ne '404') { $fail = $fail + 1 }
Write-Output ('{0,-26} {1} {2}' -f ('GONE ' + $gone), $code, $tag)
}
Write-Output '--- 内容断言 ---'
$cal = $resp['GET /calendar']
function Assert($label, $haystack, $pattern, $expectFound) {
$found = $haystack -match $pattern
$pass = ($found -eq $expectFound)
if (-not $pass) { $global:fail = $global:fail + 1 }
$verdict = if ($pass) { 'ok' } else { 'FAIL' }
$what = if ($expectFound) { 'present' } else { 'absent' }
Write-Output ('{0,-34} {1} ({2} expected)' -f $label, $verdict, $what)
}
Assert '盲水印 Powered by Baka春秋' $cal 'Powered by Baka春秋' $true
Assert 'wm-blind 容器' $cal 'class="wm-blind"' $true
Assert '品牌 代打台-L' $cal '代打台-L' $true
Assert '页脚版本 v1.11.5-L' $cal 'v1\.11\.5-L' $true
Assert '大屏入口' $cal '大屏' $false
Assert '/screen 链接' $cal '/screen' $false
Assert '通行证' $cal '通行证' $false
Assert '深渊' $cal '深渊' $false
Assert '按次' $cal '按次' $false
Assert '小键盘' $cal '小键盘' $false
Assert '打手' $cal '打手' $false
Assert 'keypad 标记' $cal 'data-keypad' $false
Write-Output '--- 实时数据断言 ---'
Assert '日历有待打任务' $live '今日打卡|当日待完成' $true
Assert '剩余 30 天' $live '剩 30 天' $true
Assert '客户尾号 1234' $live '1234' $true
Assert '今天到期徽标' $live '今日到期' $true
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Write-Output '--- server log ---'
if (Test-Path $logFile) { Get-Content $logFile -Encoding utf8 | Select-Object -First 30 }
Write-Output ('SMOKE-FAILURES=' + $fail)