@@ -0,0 +1,14 @@
|
|||||||
|
# 统一换行,避免 Windows 上提交后 CI 里脚本带 \r 报错
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
*.go text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.mod text eol=lf
|
||||||
|
*.sum text eol=lf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.md text eol=lf
|
||||||
|
|
||||||
|
*.exe binary
|
||||||
|
*.db binary
|
||||||
|
*.png binary
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
# 触发:主干推送、版本标签、PR、手动执行
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# 纯 Go 依赖,交叉编译不需要 CGO
|
||||||
|
CGO_ENABLED: "0"
|
||||||
|
# 国内网络更稳的模块代理;如需官方源可改为 https://proxy.golang.org,direct
|
||||||
|
GOPROXY: https://goproxy.cn,direct
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build-and-test
|
||||||
|
# 编译环境标签
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Go version
|
||||||
|
run: go version
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Build binaries
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
mkdir -p dist
|
||||||
|
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o dist/daili-windows-amd64.exe .
|
||||||
|
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o dist/daili-linux-amd64 .
|
||||||
|
cd dist
|
||||||
|
sha256sum daili-* > SHA256SUMS.txt
|
||||||
|
ls -lh
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
env:
|
||||||
|
ACTIONS_RESULTS_URL: https://git.code.cq.cn/
|
||||||
|
with:
|
||||||
|
name: subtrack-${{ github.run_number }}
|
||||||
|
path: dist/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# ============================================================
|
||||||
|
# 入库范围约定:
|
||||||
|
# 允许 —— 源码、构建脚本、CI 配置、README.md
|
||||||
|
# 禁止 —— 除 README.md 以外的任何开发文档(PRD / 测试清单 /
|
||||||
|
# 测试证据 / 截图等)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 开发文档:除 README.md 外一律不入库
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
/test-evidence/
|
||||||
|
/shots/
|
||||||
|
|
||||||
|
# 构建缓存(离线构建使用工作区内的缓存目录)
|
||||||
|
.gocache/
|
||||||
|
.gotmp/
|
||||||
|
|
||||||
|
# 运行期数据(数据库、备份、日志、自动生成的配置)
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
backup/
|
||||||
|
logs/
|
||||||
|
daili.ini
|
||||||
|
testdata/
|
||||||
|
testrun/
|
||||||
|
testrun2/
|
||||||
|
freshtest/
|
||||||
|
cfgtest/
|
||||||
|
cfgtest-run/
|
||||||
|
portable-run/
|
||||||
|
|
||||||
|
# 截图辅助
|
||||||
|
.chrome-profile-*/
|
||||||
|
|
||||||
|
# 编译产物
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 代打台 · 代打管理平台
|
||||||
|
|
||||||
|
一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡 → 大屏**。
|
||||||
|
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 快速开始
|
||||||
|
|
||||||
|
1. 把 `daili.exe` 放进任意文件夹(建议路径不含空格,例如 `D:\代打台\`);
|
||||||
|
2. 双击 `daili.exe`:
|
||||||
|
- 首次运行会自动生成配置文件 `daili.ini`;
|
||||||
|
- 弹出一个黑色窗口,打印访问地址(默认 **17777** 端口);
|
||||||
|
- 默认会自动打开浏览器;
|
||||||
|
3. 浏览器打开 `http://127.0.0.1:17777` → 第一次会让你**创建管理员账号**;
|
||||||
|
4. 之后按「套餐 → 客户 → 订阅」的顺序录入即可。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────┐
|
||||||
|
│ 代 打 台 · 代打管理平台 │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
版本 1.1.0 配置文件 daili.ini
|
||||||
|
数据库 D:\代打台\daili.db
|
||||||
|
访问地址:
|
||||||
|
本机 http://127.0.0.1:17777
|
||||||
|
局域网 http://192.168.1.23:17777
|
||||||
|
大屏 http://127.0.0.1:17777/screen(免登录)
|
||||||
|
注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen
|
||||||
|
按 Ctrl+C 停止服务
|
||||||
|
```
|
||||||
|
|
||||||
|
> 关掉黑窗口 = 停止服务。数据已经落盘,下次打开继续用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配置文件 daili.ini
|
||||||
|
|
||||||
|
配置文件固定放在 **exe 同级目录**,首次运行自动生成(带中文注释),改完**重启生效**。
|
||||||
|
写法:`key = value`,`#` 或 `;` 开头的整行是注释,不支持分节;**相对路径相对于 exe 所在目录**。
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `listen_ip` | `0.0.0.0` | 监听 IP。`0.0.0.0` = 所有网卡(局域网可访问);`127.0.0.1` = 只允许本机 |
|
||||||
|
| `listen_port` | `17777` | 监听端口(1–65535) |
|
||||||
|
| `database_path` | `daili.db` | SQLite 数据库文件(单文件,含全部数据) |
|
||||||
|
| `backup_enabled` | `true` | 是否每日自动备份 |
|
||||||
|
| `backup_dir` | `backup` | 备份目录(留空则用 exe 同级 `backup/`) |
|
||||||
|
| `backup_keep` | `14` | 备份保留份数(1–365),超出自动清理 |
|
||||||
|
| `timezone` | 空 | 留空跟随系统;可固定 `Asia/Shanghai`。**影响所有“自然日”计算** |
|
||||||
|
| `open_browser` | `true` | 启动后自动打开浏览器 |
|
||||||
|
| `screen_public` | `true` | 大屏 `/screen` 免登录(投屏用);改 `false` 则需登录 |
|
||||||
|
| `log_enabled` | `true` | 运行日志写文件(控制台始终输出) |
|
||||||
|
| `log_dir` | `logs` | 日志目录,按天切分 `daili-YYYY-MM-DD.log` |
|
||||||
|
|
||||||
|
**行为约定**
|
||||||
|
|
||||||
|
- 文件不存在 → 自动生成默认配置,照常启动;
|
||||||
|
- 文件写错 → **报错退出并指出行号**,原文件不会被修改。例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
[代打台] 配置文件有问题,已停止启动:
|
||||||
|
|
||||||
|
第 3 行: listen_port 必须在 1-65535 之间,当前是 99999
|
||||||
|
修好后重新启动即可(原文件未被修改)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 未知配置项、重复配置项、布尔值写成 `maybe`、时区写错,都会带行号报错;
|
||||||
|
- 端口被占用不会自动换端口,而是明确报错并提示改 `listen_port`。
|
||||||
|
|
||||||
|
### 命令行参数(临时覆盖配置,优先级更高)
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `-config <路径>` | 指定别的配置文件(默认 exe 同级 `daili.ini`) |
|
||||||
|
| `-addr <ip:端口>` | 临时覆盖监听地址,如 `-addr 127.0.0.1:19000` |
|
||||||
|
| `-db <路径>` | 临时覆盖数据库路径 |
|
||||||
|
| `-backup-keep <n>` | 临时覆盖备份保留份数 |
|
||||||
|
| `-no-backup` | 本次运行关闭自动备份 |
|
||||||
|
| `-no-open` | 本次运行不自动打开浏览器 |
|
||||||
|
|
||||||
|
示例:`daili.exe -addr 127.0.0.1:19000 -no-open`
|
||||||
|
|
||||||
|
> 业务参数(到期预警天数、大屏刷新秒数)不在配置文件里,仍在网页「设置」中修改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 第一次使用(5 分钟走完)
|
||||||
|
|
||||||
|
1. **建套餐**:`套餐 → 新增套餐`
|
||||||
|
- 「包月日常」按日 30 天,色签绿;
|
||||||
|
- 「深渊十次」按次 10 次,色签紫。
|
||||||
|
> 以后要加套餐 D/E/F,直接再加一行就行,**不会影响任何已有客户数据**。
|
||||||
|
2. **建客户**:`客户 → 新建客户`,填手机尾号 + 客户名(这两项组合唯一)。
|
||||||
|
3. **开通订阅**:进客户详情 → 选套餐、开始日、天数(按次则填次数)→ 开通订阅。
|
||||||
|
4. **日常使用**:打开「日历」,今天那格里就是这个客户今天要打的单,点「完成」打卡。
|
||||||
|
5. **续费**:客户转账后,点订阅上的「追加时长」→ 在日历上点新的终止日期 → 确认。
|
||||||
|
面板会实时显示「计算起点 / 原结束日 / 新结束日 / 本次追加几天 / 追加后剩余几天」,并在客户页留下追加记录。
|
||||||
|
|
||||||
|
> 想先看效果再录自己的数据?程序启动后运行 `powershell -ExecutionPolicy Bypass -File scripts\seed.ps1`,会写入 8 个演示客户和 4 个套餐(默认账号 `admin` / `daili123`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 业务规则(和 PRD 一致)
|
||||||
|
|
||||||
|
**剩余时长**
|
||||||
|
- 按日:`剩余天数 = 结束日 − 今天 + 1`,结束日当天仍算服务日;
|
||||||
|
- 按次:`剩余次数 = 总次数 − 已用次数`;按次套餐的「有效期」可留空,留空就一直有效直到次数用完。
|
||||||
|
|
||||||
|
**追加时长(核心)**
|
||||||
|
- 打开追加面板,**在月历上点选新的终止日期**,系统自动反算;
|
||||||
|
- 未到期的订阅:从原结束日的**次日**起算,追加天数 = 新结束日 − 原结束日;
|
||||||
|
- 已到期的订阅:**从今天起算**(不会把中间空档的天数白送给客户);
|
||||||
|
- 追加记录保留:计算起点、旧结束日、新结束日、天数、备注、操作人、时间。
|
||||||
|
|
||||||
|
**暂停 / 恢复**
|
||||||
|
- 暂停期间冻结剩余时长,日历上不出现待打任务;
|
||||||
|
- 恢复时按日套餐的结束日**自动顺延**暂停的自然日天数,时间轴上是灰色斜纹段。
|
||||||
|
|
||||||
|
**打卡**
|
||||||
|
- 按日套餐:每天一条「完成 / 跳过」,只做记录不扣天数(天数按自然日流逝);
|
||||||
|
- 按次套餐:打卡「完成」自动扣 1 次,撤销打卡返还 1 次;次数为 0 时不允许再打;
|
||||||
|
- 按次套餐如果没设有效期,它不会出现在日历里 —— 直接在客户详情点订阅卡片上的「今日打卡」扣次。
|
||||||
|
|
||||||
|
**预警**
|
||||||
|
- 剩余 ≤3 天橙色、今天到期红色、已过期灰色;顶栏常驻「今日到期 / 3 天内」两个数字。
|
||||||
|
|
||||||
|
**查询**
|
||||||
|
- 顶栏搜索框输入手机尾号即可定位客户(也支持姓名、游戏名、联系方式),按 `/` 快速聚焦。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据、备份与导出
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `daili.db` | **全部数据都在这一个文件里**(SQLite,单文件回滚日志模式,运行中也不会产生 `-wal`/`-shm`) |
|
||||||
|
| `backup/daili-YYYYMMDD-HHMMSS.db` | 每天首次启动时自动备份一份,保留份数见 `backup_keep` |
|
||||||
|
| `logs/daili-YYYY-MM-DD.log` | 运行日志,按天切分 |
|
||||||
|
|
||||||
|
### 迁移与重置(只说一次,很重要)
|
||||||
|
|
||||||
|
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
|
||||||
|
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。
|
||||||
|
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
|
||||||
|
|
||||||
|
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
|
||||||
|
- 导出:`设置 → 导出客户 / 订阅 / 日志 CSV`,UTF-8 BOM,Excel 直接打开不乱码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 大屏
|
||||||
|
|
||||||
|
- 地址:`http://<本机IP>:17777/screen`
|
||||||
|
- 默认 `screen_public = true`:**免登录**,电视/副屏直接打开即可(仅建议局域网使用);
|
||||||
|
- 改成 `screen_public = false` 后大屏回到登录保护;
|
||||||
|
- 深色「记分板」风格:客户数 / 活跃订阅 / 今日到期 / 7 天内到期、到期热力月历、即将到期 TOP10、今日待打、最近追加;
|
||||||
|
- 默认 30 秒自动刷新(设置页可改),按 `F` 全屏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 从源码构建
|
||||||
|
|
||||||
|
环境:Windows + Go 1.25(本项目在 `go1.25.6` 上验证)。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.ps1` 会设置 `GOPROXY=off`、`GOCACHE=<项目>\.gocache`,用本地模块缓存离线构建,产物为 `daili.exe`(约 13.5 MB,内含 SQLite 引擎、时区库、模板与静态资源,不依赖任何 DLL)。
|
||||||
|
|
||||||
|
> **为什么有 `third_party/fileutil`**:SQLite 驱动 `modernc.org/sqlite` 的**测试代码**引用了 `modernc.org/fileutil`,但编译产物并不依赖它。本机无法访问任何 Go 模块源,因此用一个空模块占位,仅用于满足模块图解析。
|
||||||
|
>
|
||||||
|
> **为什么内嵌时区库**:Windows 没有 `/usr/share/zoneinfo`,为了 `timezone = Asia/Shanghai` 在任何机器上都可用,引入了 `time/tzdata`(约 0.5 MB)。
|
||||||
|
|
||||||
|
测试:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GOFLAGS='-mod=mod'; $env:GOPROXY='off'; $env:GOCACHE="$PWD\.gocache"
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
目录结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
main.go 程序入口(读配置、日志、时区、监听、备份)
|
||||||
|
internal/config daili.ini 的生成/解析/校验(含单测)
|
||||||
|
internal/domain 纯业务计算(日期、剩余天数、状态、追加换算)+ 单元测试
|
||||||
|
internal/store SQLite 连接、迁移、全部读写 + 单元测试
|
||||||
|
internal/logx 控制台 + 按天切分的运行日志
|
||||||
|
internal/pw 口令散列(PBKDF2-SHA256,标准库实现)
|
||||||
|
internal/web 路由、会话、模板渲染、处理器
|
||||||
|
internal/web/templates 页面与布局(embed 进二进制)
|
||||||
|
internal/web/static app.css / screen.css / app.js / screen.js
|
||||||
|
scripts/acceptance.ps1 冒烟清单执行器(119 条断言)
|
||||||
|
scripts/config-test.ps1 配置文件专项测试(14 条断言)
|
||||||
|
scripts/smoke.ps1 端到端冒烟(早期版本,保留)
|
||||||
|
scripts/seed.ps1 写入演示数据
|
||||||
|
scripts/dbtool.py 测试预处理(暂停回溯、批量造数、完整性检查)
|
||||||
|
.gitea/workflows/build.yml Gitea Actions:vet → test → 交叉编译 → 上传产物
|
||||||
|
.gitattributes 统一换行(.go/.yml 用 LF,.ps1 用 CRLF)
|
||||||
|
PRD.md 产品需求文档(本地文档,不入库)
|
||||||
|
TEST-CHECKLIST.md 冒烟测试清单 + 执行结果 + 缺陷记录(本地,不入库)
|
||||||
|
test-evidence/ 测试证据(本地,不入库)
|
||||||
|
daili.ini 首次运行自动生成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 持续集成(Gitea Actions)
|
||||||
|
|
||||||
|
仓库:<https://git.code.cq.cn/chun_qiu/subtrack.git>,CI 配置在 `.gitea/workflows/build.yml`。
|
||||||
|
|
||||||
|
- 触发:推送到 `main` / `master`、打 `v*` 标签、PR、手动执行;
|
||||||
|
- 运行环境:`runs-on: ubuntu-latest`;Go 版本由 `actions/setup-go@v5` 读取 `go.mod`(1.25);
|
||||||
|
- 步骤:`go vet ./...` → `go test ./...` → 交叉编译 Windows / Linux 两个二进制 → `sha256sum` → 用 `actions/upload-artifact@v4` 上传;
|
||||||
|
- 上传步骤带 `env: ACTIONS_RESULTS_URL: https://git.code.cq.cn/`;
|
||||||
|
- 产物:`subtrack-<run_number>`,内含 `daili-windows-amd64.exe`、`daili-linux-amd64`、`SHA256SUMS.txt`,保留 14 天;
|
||||||
|
- 模块代理默认 `https://goproxy.cn,direct`,如在海外或内网构建,改 workflow 顶部的 `GOPROXY` 即可。
|
||||||
|
|
||||||
|
> 仓库只入库源码、构建脚本、CI 配置和本 README;PRD、测试清单、截图与测试证据都在 `.gitignore` 里排除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
**Windows 提示「已保护你的电脑」**
|
||||||
|
点「更多信息 → 仍要运行」。这是 Windows 对未签名 exe 的默认拦截。
|
||||||
|
|
||||||
|
**端口被占用 / 想换端口**
|
||||||
|
改 `daili.ini` 里的 `listen_port` 后重启;临时换端口可用 `daili.exe -addr :19000`。
|
||||||
|
|
||||||
|
**只想自己用,不让局域网访问**
|
||||||
|
把 `listen_ip` 改成 `127.0.0.1`,启动横幅会提示「仅本机可访问」。
|
||||||
|
|
||||||
|
**手机打不开局域网地址**
|
||||||
|
1. 确认 `listen_ip` 是 `0.0.0.0`;
|
||||||
|
2. 确认手机和电脑在同一个 WiFi;
|
||||||
|
3. Windows 防火墙首次会弹窗,选「允许访问」(专用网络);
|
||||||
|
4. 用启动时打印的那个 `192.168.x.x` 地址,不要用 `127.0.0.1`。
|
||||||
|
|
||||||
|
**忘记密码**
|
||||||
|
让另一位管理员在 `设置 → 管理员` 里新增一个账户即可;如果只剩一个管理员且忘了密码,只能删掉 `daili.db` 重来。
|
||||||
|
|
||||||
|
**数据会不会因为程序崩溃丢?**
|
||||||
|
单文件 SQLite + 每次操作即时落盘,正常使用不会丢;每天的自动备份是双保险。仍然建议定期把 `backup/` 拷到别的盘。
|
||||||
|
|
||||||
|
**能放到公网吗?**
|
||||||
|
不建议。程序按「局域网内可信环境」设计:会话 Cookie 有 HttpOnly / SameSite 保护,但没有 HTTPS、没有登录失败锁定,且大屏默认免登录。要放公网请套一层反向代理 + HTTPS + 访问控制,并把 `screen_public` 改成 `false`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 安全说明
|
||||||
|
|
||||||
|
- 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆;
|
||||||
|
- 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询;
|
||||||
|
- 服务端模板全部走 `html/template` 自动转义;
|
||||||
|
- 所有 POST 校验 `Origin`,会话 Cookie 使用 `SameSite=Lax` + `HttpOnly`;
|
||||||
|
- 配置文件里的数据库/备份/日志路径请自行确保目录权限,程序不会覆盖已有文件(备份重名会跳过)。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 离线构建脚本:本机无法访问任何 Go 模块源,全部依赖取自本地模块缓存
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$env:GOFLAGS = '-mod=mod'
|
||||||
|
$env:GOPROXY = 'off'
|
||||||
|
$env:GOSUMDB = 'off'
|
||||||
|
$env:GOCACHE = Join-Path $root '.gocache'
|
||||||
|
$env:GOTMPDIR = Join-Path $root '.gotmp'
|
||||||
|
New-Item -ItemType Directory -Force -Path $env:GOCACHE, $env:GOTMPDIR | Out-Null
|
||||||
|
|
||||||
|
Push-Location $root
|
||||||
|
try {
|
||||||
|
Write-Host '正在编译…' -ForegroundColor Cyan
|
||||||
|
go build -trimpath -ldflags '-s -w' -o daili.exe .
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "编译失败(退出码 $LASTEXITCODE)" }
|
||||||
|
$f = Get-Item daili.exe
|
||||||
|
Write-Host ("编译完成: {0} {1:N1} MB" -f $f.Name, ($f.Length / 1MB)) -ForegroundColor Green
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module daili
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require modernc.org/sqlite v1.50.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
// 离线构建用占位模块:sqlite 的测试代码引用了 modernc.org/fileutil,
|
||||||
|
// 但编译产物不依赖它,此处用空模块满足模块图解析(本机无法访问任何 Go 模块源)。
|
||||||
|
replace modernc.org/fileutil => ./third_party/fileutil
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// Package config 负责读取/生成与可执行文件同级的 daili.ini。
|
||||||
|
//
|
||||||
|
// 设计约定:
|
||||||
|
// - 只放「部署类」参数;业务默认值(预警天数、大屏刷新秒数)仍在设置页(数据库)。
|
||||||
|
// - 文件不存在时自动生成带注释的默认配置;文件写错时报错并保留原文件。
|
||||||
|
// - 相对路径一律相对于可执行文件所在目录,而不是当前工作目录。
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
// 内嵌时区库:Windows 上没有 /usr/share/zoneinfo,靠它才能 LoadLocation("Asia/Shanghai")
|
||||||
|
_ "time/tzdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileName 是默认配置文件名。
|
||||||
|
const FileName = "daili.ini"
|
||||||
|
|
||||||
|
// Config 是最终生效的配置。
|
||||||
|
type Config struct {
|
||||||
|
ListenIP string
|
||||||
|
ListenPort int
|
||||||
|
DatabasePath string
|
||||||
|
BackupEnabled bool
|
||||||
|
BackupDir string
|
||||||
|
BackupKeep int
|
||||||
|
Timezone string
|
||||||
|
OpenBrowser bool
|
||||||
|
ScreenPublic bool
|
||||||
|
LogEnabled bool
|
||||||
|
LogDir string
|
||||||
|
|
||||||
|
// 运行时附加信息
|
||||||
|
Path string // 配置文件绝对路径
|
||||||
|
BaseDir string // 相对路径的基准目录(exe 所在目录)
|
||||||
|
Created bool // 本次是否自动生成了默认文件
|
||||||
|
|
||||||
|
keyLine map[string]int // 各配置项出现的行号,用于校验报错定位
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults 返回内置默认值。
|
||||||
|
func Defaults() *Config {
|
||||||
|
return &Config{
|
||||||
|
ListenIP: "0.0.0.0",
|
||||||
|
ListenPort: 17777,
|
||||||
|
DatabasePath: "daili.db",
|
||||||
|
BackupEnabled: true,
|
||||||
|
BackupDir: "backup",
|
||||||
|
BackupKeep: 14,
|
||||||
|
Timezone: "",
|
||||||
|
OpenBrowser: true,
|
||||||
|
ScreenPublic: true,
|
||||||
|
LogEnabled: true,
|
||||||
|
LogDir: "logs",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr 返回 net.Listen 用的监听地址。
|
||||||
|
func (c *Config) Addr() string {
|
||||||
|
return net.JoinHostPort(c.ListenIP, strconv.Itoa(c.ListenPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoopbackOnly 表示只监听本机回环地址。
|
||||||
|
func (c *Config) LoopbackOnly() bool {
|
||||||
|
ip := net.ParseIP(c.ListenIP)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicBind 表示监听在所有网卡(局域网可访问)。
|
||||||
|
func (c *Config) PublicBind() bool {
|
||||||
|
ip := net.ParseIP(c.ListenIP)
|
||||||
|
return ip != nil && (ip.IsUnspecified())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 读取配置;文件不存在时生成默认文件。baseDir 为空则取配置文件所在目录。
|
||||||
|
func Load(path, baseDir string) (*Config, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if baseDir == "" {
|
||||||
|
baseDir = filepath.Dir(abs)
|
||||||
|
}
|
||||||
|
c := Defaults()
|
||||||
|
c.Path = abs
|
||||||
|
c.BaseDir = baseDir
|
||||||
|
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
if mkErr := os.MkdirAll(filepath.Dir(abs), 0o755); mkErr != nil {
|
||||||
|
return nil, fmt.Errorf("创建配置目录失败: %w", mkErr)
|
||||||
|
}
|
||||||
|
c.Created = true
|
||||||
|
if wErr := os.WriteFile(abs, []byte(templateText(c)), 0o644); wErr != nil {
|
||||||
|
return nil, fmt.Errorf("生成默认配置文件失败: %w", wErr)
|
||||||
|
}
|
||||||
|
return c.resolve(), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.parse(string(data)); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s\n修好后重新启动即可(原文件未被修改)", err)
|
||||||
|
}
|
||||||
|
return c.resolve(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) resolve() *Config {
|
||||||
|
c.DatabasePath = absolute(c.BaseDir, c.DatabasePath)
|
||||||
|
c.BackupDir = absolute(c.BaseDir, c.BackupDir)
|
||||||
|
c.LogDir = absolute(c.BaseDir, c.LogDir)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func absolute(base, p string) string {
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(p) {
|
||||||
|
return filepath.Clean(p)
|
||||||
|
}
|
||||||
|
return filepath.Join(base, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) parse(text string) error {
|
||||||
|
text = strings.TrimPrefix(text, "\ufeff")
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
seen := map[string]int{}
|
||||||
|
c.keyLine = map[string]int{}
|
||||||
|
for i, raw := range lines {
|
||||||
|
line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "[") {
|
||||||
|
return fmt.Errorf("第 %d 行: 不支持分节([xxx]),请直接写 key = value", i+1)
|
||||||
|
}
|
||||||
|
eq := strings.Index(line, "=")
|
||||||
|
if eq <= 0 {
|
||||||
|
return fmt.Errorf("第 %d 行: 缺少等号,正确格式是 key = value", i+1)
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(line[:eq]))
|
||||||
|
val := strings.TrimSpace(line[eq+1:])
|
||||||
|
if prev, ok := seen[key]; ok {
|
||||||
|
return fmt.Errorf("第 %d 行: 配置项 %s 与第 %d 行重复", i+1, key, prev)
|
||||||
|
}
|
||||||
|
seen[key] = i + 1
|
||||||
|
c.keyLine[key] = i + 1
|
||||||
|
if err := c.set(key, val); err != nil {
|
||||||
|
return fmt.Errorf("第 %d 行: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) set(key, val string) error {
|
||||||
|
switch key {
|
||||||
|
case "listen_ip":
|
||||||
|
c.ListenIP = val
|
||||||
|
case "listen_port":
|
||||||
|
n, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen_port 必须是数字,当前是 %q", val)
|
||||||
|
}
|
||||||
|
c.ListenPort = n
|
||||||
|
case "database_path":
|
||||||
|
c.DatabasePath = val
|
||||||
|
case "backup_enabled":
|
||||||
|
b, err := parseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup_enabled %v", err)
|
||||||
|
}
|
||||||
|
c.BackupEnabled = b
|
||||||
|
case "backup_dir":
|
||||||
|
c.BackupDir = val
|
||||||
|
case "backup_keep":
|
||||||
|
n, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup_keep 必须是数字,当前是 %q", val)
|
||||||
|
}
|
||||||
|
c.BackupKeep = n
|
||||||
|
case "timezone":
|
||||||
|
c.Timezone = val
|
||||||
|
case "open_browser":
|
||||||
|
b, err := parseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open_browser %v", err)
|
||||||
|
}
|
||||||
|
c.OpenBrowser = b
|
||||||
|
case "screen_public":
|
||||||
|
b, err := parseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("screen_public %v", err)
|
||||||
|
}
|
||||||
|
c.ScreenPublic = b
|
||||||
|
case "log_enabled":
|
||||||
|
b, err := parseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("log_enabled %v", err)
|
||||||
|
}
|
||||||
|
c.LogEnabled = b
|
||||||
|
case "log_dir":
|
||||||
|
c.LogDir = val
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("未知配置项 %s(可用项见文件内的注释)", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
if c.ListenIP == "" {
|
||||||
|
return c.at("listen_ip", "listen_ip 不能为空(0.0.0.0 表示所有网卡,127.0.0.1 表示仅本机)")
|
||||||
|
}
|
||||||
|
if net.ParseIP(c.ListenIP) == nil {
|
||||||
|
return c.at("listen_ip", "listen_ip %q 不是合法 IP(请写 0.0.0.0 或 127.0.0.1)", c.ListenIP)
|
||||||
|
}
|
||||||
|
if c.ListenPort < 1 || c.ListenPort > 65535 {
|
||||||
|
return c.at("listen_port", "listen_port 必须在 1-65535 之间,当前是 %d", c.ListenPort)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.DatabasePath) == "" {
|
||||||
|
return c.at("database_path", "database_path 不能为空")
|
||||||
|
}
|
||||||
|
if c.BackupEnabled && strings.TrimSpace(c.BackupDir) == "" {
|
||||||
|
return c.at("backup_dir", "backup_enabled 为 true 时 backup_dir 不能为空")
|
||||||
|
}
|
||||||
|
if c.BackupKeep < 1 || c.BackupKeep > 365 {
|
||||||
|
return c.at("backup_keep", "backup_keep 必须在 1-365 之间,当前是 %d", c.BackupKeep)
|
||||||
|
}
|
||||||
|
if c.Timezone != "" {
|
||||||
|
if _, err := time.LoadLocation(c.Timezone); err != nil {
|
||||||
|
return c.at("timezone", "timezone %q 无法识别(示例:Asia/Shanghai;留空表示跟随系统)", c.Timezone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.LogEnabled && strings.TrimSpace(c.LogDir) == "" {
|
||||||
|
return c.at("log_dir", "log_enabled 为 true 时 log_dir 不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// at 生成带行号的错误;没有行号信息(如默认值)时退化为普通错误。
|
||||||
|
func (c *Config) at(key, format string, args ...any) error {
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
if n := c.keyLine[key]; n > 0 {
|
||||||
|
return fmt.Errorf("第 %d 行: %s", n, msg)
|
||||||
|
}
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 校验配置(命令行覆盖之后也会调用一次)。
|
||||||
|
func (c *Config) Validate() error { return c.validate() }
|
||||||
|
|
||||||
|
func parseBool(v string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "1", "true", "yes", "on", "y":
|
||||||
|
return true, nil
|
||||||
|
case "0", "false", "no", "off", "n":
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("必须是 true/false(也接受 1/0、yes/no、on/off),当前是 %q", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateText(c *Config) string {
|
||||||
|
return fmt.Sprintf(`# ============================================================
|
||||||
|
# 代打台 配置文件(daili.ini)
|
||||||
|
# 与 daili.exe 放在同一目录;修改后需要重启程序生效。
|
||||||
|
# 写法:key = value;以 # 或 ; 开头的整行是注释;不支持分节。
|
||||||
|
# 相对路径相对于 exe 所在目录,而不是当前工作目录。
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ---------- 监听 ----------
|
||||||
|
# 0.0.0.0 = 所有网卡(同局域网可访问)
|
||||||
|
# 127.0.0.1 = 只允许本机访问
|
||||||
|
listen_ip = %s
|
||||||
|
|
||||||
|
listen_port = %d
|
||||||
|
|
||||||
|
# ---------- 数据与备份 ----------
|
||||||
|
database_path = %s
|
||||||
|
|
||||||
|
# 是否每日自动备份(另可在网页「设置 → 立即备份」手动备份)
|
||||||
|
backup_enabled = %t
|
||||||
|
|
||||||
|
# 备份目录;留空则使用 exe 同级 backup/
|
||||||
|
backup_dir = %s
|
||||||
|
|
||||||
|
# 备份保留份数(1-365),超出的旧备份会自动清理
|
||||||
|
backup_keep = %d
|
||||||
|
|
||||||
|
# ---------- 时间 ----------
|
||||||
|
# 留空 = 跟随系统时区;也可固定为 Asia/Shanghai
|
||||||
|
timezone = %s
|
||||||
|
|
||||||
|
# ---------- 界面 ----------
|
||||||
|
# 启动后自动打开浏览器
|
||||||
|
open_browser = %t
|
||||||
|
|
||||||
|
# 大屏 /screen 是否免登录(默认 true:适合投屏;改成 false 则需要登录)
|
||||||
|
screen_public = %t
|
||||||
|
|
||||||
|
# ---------- 日志 ----------
|
||||||
|
# 运行日志同时输出到控制台与 log_dir 下按天切分的文件
|
||||||
|
log_enabled = %t
|
||||||
|
|
||||||
|
log_dir = %s
|
||||||
|
`,
|
||||||
|
c.ListenIP,
|
||||||
|
c.ListenPort,
|
||||||
|
c.DatabasePath,
|
||||||
|
c.BackupEnabled,
|
||||||
|
c.BackupDir,
|
||||||
|
c.BackupKeep,
|
||||||
|
c.Timezone,
|
||||||
|
c.OpenBrowser,
|
||||||
|
c.ScreenPublic,
|
||||||
|
c.LogEnabled,
|
||||||
|
c.LogDir,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, dir, name, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatalf("写测试配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCreatesDefaultFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, FileName)
|
||||||
|
|
||||||
|
cfg, err := Load(path, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load 失败: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Created {
|
||||||
|
t.Fatal("首次加载应标记为已生成")
|
||||||
|
}
|
||||||
|
if cfg.ListenIP != "0.0.0.0" || cfg.ListenPort != 17777 {
|
||||||
|
t.Fatalf("默认监听地址不对: %s:%d", cfg.ListenIP, cfg.ListenPort)
|
||||||
|
}
|
||||||
|
if cfg.BackupKeep != 14 || !cfg.BackupEnabled || !cfg.ScreenPublic || !cfg.LogEnabled {
|
||||||
|
t.Fatalf("默认值不对: %+v", cfg)
|
||||||
|
}
|
||||||
|
// 相对路径应解析到 exe 目录
|
||||||
|
if cfg.DatabasePath != filepath.Join(dir, "daili.db") {
|
||||||
|
t.Fatalf("数据库路径=%s", cfg.DatabasePath)
|
||||||
|
}
|
||||||
|
if cfg.BackupDir != filepath.Join(dir, "backup") || cfg.LogDir != filepath.Join(dir, "logs") {
|
||||||
|
t.Fatalf("备份/日志目录=%s %s", cfg.BackupDir, cfg.LogDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("默认配置文件未生成: %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, want := range []string{"listen_ip = 0.0.0.0", "listen_port = 17777", "代打台 配置文件(daili.ini)", "timezone ="} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("默认配置缺少 %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再读一次不应重新生成
|
||||||
|
cfg2, err := Load(path, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg2.Created {
|
||||||
|
t.Fatal("第二次加载不应标记为已生成")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadParsesValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
body := "\ufeff# 注释\n" +
|
||||||
|
"listen_ip = 127.0.0.1\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"listen_port = 19000\r\n" +
|
||||||
|
" database_path = data/mydb.db \r\n" +
|
||||||
|
"backup_enabled = no\r\n" +
|
||||||
|
"backup_keep = 30\r\n" +
|
||||||
|
"timezone = Asia/Shanghai\r\n" +
|
||||||
|
"open_browser = 0\r\n" +
|
||||||
|
"screen_public = false\r\n" +
|
||||||
|
"log_enabled = on\r\n" +
|
||||||
|
"log_dir = D:\\logs\r\n"
|
||||||
|
path := writeFile(t, dir, FileName, body)
|
||||||
|
|
||||||
|
cfg, err := Load(path, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load 失败: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ListenIP != "127.0.0.1" || cfg.ListenPort != 19000 {
|
||||||
|
t.Fatalf("监听=%s:%d", cfg.ListenIP, cfg.ListenPort)
|
||||||
|
}
|
||||||
|
if !cfg.LoopbackOnly() || cfg.PublicBind() {
|
||||||
|
t.Fatal("127.0.0.1 应判定为仅本机")
|
||||||
|
}
|
||||||
|
if cfg.DatabasePath != filepath.Join(dir, "data", "mydb.db") {
|
||||||
|
t.Fatalf("相对路径未按 exe 目录解析: %s", cfg.DatabasePath)
|
||||||
|
}
|
||||||
|
if cfg.BackupEnabled || cfg.BackupKeep != 30 {
|
||||||
|
t.Fatalf("备份配置=%v/%d", cfg.BackupEnabled, cfg.BackupKeep)
|
||||||
|
}
|
||||||
|
if cfg.Timezone != "Asia/Shanghai" || cfg.OpenBrowser || cfg.ScreenPublic || !cfg.LogEnabled {
|
||||||
|
t.Fatalf("其余配置不对: %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.LogDir != "D:\\logs" {
|
||||||
|
t.Fatalf("绝对路径被改写: %s", cfg.LogDir)
|
||||||
|
}
|
||||||
|
if cfg.Addr() != "127.0.0.1:19000" {
|
||||||
|
t.Fatalf("Addr=%s", cfg.Addr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsBadInput(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"未知配置项", "listen_port = 1\nlisten_prt = 2\n", "未知配置项 listen_prt"},
|
||||||
|
{"端口非数字", "\nlisten_port = abc\n", "listen_port 必须是数字"},
|
||||||
|
{"端口越界", "listen_port = 70000\n", "必须在 1-65535 之间"},
|
||||||
|
{"非法IP", "listen_ip = 300.1.1.1\n", "不是合法 IP"},
|
||||||
|
{"布尔值非法", "open_browser = maybe\n", "必须是 true/false"},
|
||||||
|
{"保留份数越界", "backup_keep = 0\n", "backup_keep 必须在 1-365 之间"},
|
||||||
|
{"时区非法", "timezone = Mars/Base\n", "timezone"},
|
||||||
|
{"重复配置", "listen_port = 1\nlisten_port = 2\n", "重复"},
|
||||||
|
{"缺少等号", "listen_port 1\n", "缺少等号"},
|
||||||
|
{"不支持分节", "[net]\nlisten_port = 1\n", "不支持分节"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeFile(t, dir, FileName, c.body)
|
||||||
|
before, _ := os.ReadFile(path)
|
||||||
|
_, err := Load(path, dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("期望报错,实际通过")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Fatalf("错误信息 %q 不含 %q", err.Error(), c.want)
|
||||||
|
}
|
||||||
|
after, _ := os.ReadFile(path)
|
||||||
|
if string(before) != string(after) {
|
||||||
|
t.Fatal("出错时不应修改原文件")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorHasLineNumber(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeFile(t, dir, FileName, "# 注释\nlisten_ip = 0.0.0.0\nlisten_port = 99999\n")
|
||||||
|
_, err := Load(path, dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("期望报错")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "第 3 行") {
|
||||||
|
t.Fatalf("错误信息应带行号: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAfterOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeFile(t, dir, FileName, "listen_port = 18080\n")
|
||||||
|
cfg, err := Load(path, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg.ListenPort = 0
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("覆盖后的非法端口应被拦截")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DateLayout = "2006-01-02"
|
||||||
|
StampLayout = "2006-01-02 15:04:05"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Today 返回本地时区当天零点。
|
||||||
|
func Today() time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDate 解析 YYYY-MM-DD。
|
||||||
|
func ParseDate(s string) (time.Time, error) {
|
||||||
|
return time.ParseInLocation(DateLayout, s, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDateOr 解析失败时返回默认值。
|
||||||
|
func ParseDateOr(s string, fallback time.Time) time.Time {
|
||||||
|
if t, err := ParseDate(s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date 格式化为 YYYY-MM-DD。
|
||||||
|
func Date(t time.Time) string { return t.Format(DateLayout) }
|
||||||
|
|
||||||
|
// DayDiff 返回 b-a 的自然日天数(不受夏令时影响)。
|
||||||
|
func DayDiff(a, b time.Time) int {
|
||||||
|
ay, am, ad := a.Date()
|
||||||
|
by, bm, bd := b.Date()
|
||||||
|
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
|
||||||
|
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
|
||||||
|
return int(ub.Sub(ua).Hours() / 24.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDays 加 n 天。
|
||||||
|
func AddDays(t time.Time, n int) time.Time { return t.AddDate(0, 0, n) }
|
||||||
|
|
||||||
|
// MonthStart 返回当月 1 日。
|
||||||
|
func MonthStart(y int, m time.Month) time.Time {
|
||||||
|
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonthEnd 返回当月最后一天。
|
||||||
|
func MonthEnd(y int, m time.Month) time.Time {
|
||||||
|
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local).AddDate(0, 1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekdayIndex 返回以周一为 0 的星期索引。
|
||||||
|
func WeekdayIndex(t time.Time) int {
|
||||||
|
wd := int(t.Weekday())
|
||||||
|
if wd == 0 {
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
return wd - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅状态(存储值)
|
||||||
|
const (
|
||||||
|
StatusActive = "active"
|
||||||
|
StatusPaused = "paused"
|
||||||
|
StatusClosed = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 套餐类型
|
||||||
|
const (
|
||||||
|
UnitDay = "day"
|
||||||
|
UnitCount = "count"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 派生展示状态
|
||||||
|
const (
|
||||||
|
StateActive = "active" // 进行中
|
||||||
|
StatePending = "pending" // 未开始
|
||||||
|
StatePaused = "paused" // 暂停中
|
||||||
|
StateExpired = "expired" // 已到期
|
||||||
|
StateUsedUp = "usedup" // 次数用尽
|
||||||
|
StateClosed = "closed" // 已结束
|
||||||
|
)
|
||||||
|
|
||||||
|
// 到期预警等级
|
||||||
|
const (
|
||||||
|
LevelOK = "ok"
|
||||||
|
LevelWarn = "warn"
|
||||||
|
LevelToday = "today"
|
||||||
|
LevelExpired = "expired"
|
||||||
|
LevelNone = "none" // 无期限(按次且未设有效期)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Eval 是订阅的计算结果。
|
||||||
|
type Eval struct {
|
||||||
|
State string
|
||||||
|
DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日
|
||||||
|
RemainingDays int
|
||||||
|
RemainingCount int
|
||||||
|
Level string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalSub 计算订阅的展示状态与剩余量。
|
||||||
|
// - unit=day:剩余天数 = end - today + 1(含当天)
|
||||||
|
// - unit=count:剩余次数 = total - used;end 为可选有效期
|
||||||
|
func EvalSub(unit, status, startDate, endDate string, total, used int, today time.Time, warnDays int) Eval {
|
||||||
|
e := Eval{Level: LevelNone, DaysLeft: -1}
|
||||||
|
if warnDays <= 0 {
|
||||||
|
warnDays = 3
|
||||||
|
}
|
||||||
|
start := ParseDateOr(startDate, today)
|
||||||
|
end, hasEnd := time.Time{}, false
|
||||||
|
if endDate != "" {
|
||||||
|
if t, err := ParseDate(endDate); err == nil {
|
||||||
|
end, hasEnd = t, true
|
||||||
|
e.DaysLeft = DayDiff(today, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unit == UnitDay {
|
||||||
|
if hasEnd {
|
||||||
|
e.RemainingDays = e.DaysLeft + 1
|
||||||
|
}
|
||||||
|
if e.RemainingDays < 0 {
|
||||||
|
e.RemainingDays = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.RemainingCount = total - used
|
||||||
|
if e.RemainingCount < 0 {
|
||||||
|
e.RemainingCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expired := false
|
||||||
|
if hasEnd && end.Before(today) {
|
||||||
|
expired = true
|
||||||
|
}
|
||||||
|
if unit == UnitCount && e.RemainingCount <= 0 {
|
||||||
|
expired = true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case status == StatusClosed:
|
||||||
|
e.State = StateClosed
|
||||||
|
case status == StatusPaused:
|
||||||
|
e.State = StatePaused
|
||||||
|
case expired:
|
||||||
|
if unit == UnitCount && e.RemainingCount <= 0 {
|
||||||
|
e.State = StateUsedUp
|
||||||
|
} else {
|
||||||
|
e.State = StateExpired
|
||||||
|
}
|
||||||
|
case start.After(today):
|
||||||
|
e.State = StatePending
|
||||||
|
default:
|
||||||
|
e.State = StateActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预警等级只对「进行中 / 未开始」有意义
|
||||||
|
switch e.State {
|
||||||
|
case StateExpired:
|
||||||
|
e.Level = LevelExpired
|
||||||
|
case StateActive, StatePending:
|
||||||
|
switch {
|
||||||
|
case !hasEnd:
|
||||||
|
e.Level = LevelNone
|
||||||
|
case e.DaysLeft <= 0:
|
||||||
|
e.Level = LevelToday
|
||||||
|
case e.DaysLeft <= warnDays:
|
||||||
|
e.Level = LevelWarn
|
||||||
|
default:
|
||||||
|
e.Level = LevelOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendPlan 描述一次「按日追加」的换算结果。
|
||||||
|
type ExtendPlan struct {
|
||||||
|
Base time.Time // 计算起点
|
||||||
|
OldEnd time.Time
|
||||||
|
NewEnd time.Time
|
||||||
|
Days int
|
||||||
|
SameDay bool // 新结束日等于原结束日(未增加)
|
||||||
|
Backwards bool // 新结束日早于计算起点
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanExtend 根据原结束日、今天与新结束日,算出计算起点与追加天数。
|
||||||
|
// 规则(PRD R4):已到期从今天起算,未到期从原结束日次日起算。
|
||||||
|
func PlanExtend(oldEnd, newEnd, today time.Time) ExtendPlan {
|
||||||
|
p := ExtendPlan{OldEnd: oldEnd, NewEnd: newEnd}
|
||||||
|
if !oldEnd.Before(today) {
|
||||||
|
p.Base = AddDays(oldEnd, 1)
|
||||||
|
} else {
|
||||||
|
p.Base = today
|
||||||
|
}
|
||||||
|
p.Days = DayDiff(p.Base, newEnd) + 1
|
||||||
|
p.SameDay = DayDiff(oldEnd, newEnd) == 0
|
||||||
|
p.Backwards = p.Days <= 0
|
||||||
|
if p.Days < 0 {
|
||||||
|
p.Days = 0
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func d(y int, m time.Month, day int) time.Time {
|
||||||
|
return time.Date(y, m, day, 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDayDiff(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b time.Time
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{d(2026, 1, 1), d(2026, 1, 1), 0},
|
||||||
|
{d(2026, 1, 1), d(2026, 1, 31), 30},
|
||||||
|
{d(2026, 2, 28), d(2026, 3, 1), 1},
|
||||||
|
{d(2024, 2, 28), d(2024, 3, 1), 2}, // 闰年
|
||||||
|
{d(2026, 12, 31), d(2027, 1, 1), 1},
|
||||||
|
{d(2026, 1, 10), d(2026, 1, 1), -9},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := DayDiff(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("DayDiff(%s,%s)=%d want %d", Date(c.a), Date(c.b), got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvalDay(t *testing.T) {
|
||||||
|
today := d(2026, 10, 18)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status string
|
||||||
|
end string
|
||||||
|
wantState string
|
||||||
|
wantLevel string
|
||||||
|
wantDays int
|
||||||
|
}{
|
||||||
|
{"进行中", "active", "2026-11-16", StateActive, LevelOK, 30},
|
||||||
|
{"今天到期", "active", "2026-10-18", StateActive, LevelToday, 1},
|
||||||
|
{"3天内", "active", "2026-10-20", StateActive, LevelWarn, 3},
|
||||||
|
{"已到期", "active", "2026-10-17", StateExpired, LevelExpired, 0},
|
||||||
|
{"暂停中", "paused", "2026-11-16", StatePaused, LevelNone, 30},
|
||||||
|
{"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
e := EvalSub(UnitDay, c.status, "2026-10-01", c.end, 0, 0, today, 3)
|
||||||
|
if e.State != c.wantState {
|
||||||
|
t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState)
|
||||||
|
}
|
||||||
|
if e.Level != c.wantLevel {
|
||||||
|
t.Errorf("%s: level=%s want %s", c.name, e.Level, c.wantLevel)
|
||||||
|
}
|
||||||
|
if e.RemainingDays != c.wantDays {
|
||||||
|
t.Errorf("%s: remaining=%d want %d", c.name, e.RemainingDays, c.wantDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvalDayNotStarted(t *testing.T) {
|
||||||
|
today := d(2026, 10, 18)
|
||||||
|
e := EvalSub(UnitDay, "active", "2026-10-20", "2026-11-18", 0, 0, today, 3)
|
||||||
|
if e.State != StatePending {
|
||||||
|
t.Fatalf("state=%s want pending", e.State)
|
||||||
|
}
|
||||||
|
if e.RemainingDays != 32 {
|
||||||
|
t.Fatalf("remaining=%d want 32", e.RemainingDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvalCount(t *testing.T) {
|
||||||
|
today := d(2026, 10, 18)
|
||||||
|
|
||||||
|
// 有次数、有有效期,10 天后到期 → 正常
|
||||||
|
e := EvalSub(UnitCount, "active", "2026-10-01", "2026-10-28", 10, 4, today, 3)
|
||||||
|
if e.State != StateActive || e.Level != LevelOK || e.RemainingCount != 6 {
|
||||||
|
t.Fatalf("got state=%s level=%s remain=%d", e.State, e.Level, e.RemainingCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有效期就是今天 → 今天到期(而不是误判成 warn/ok)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanExtend(t *testing.T) {
|
||||||
|
today := d(2026, 10, 18)
|
||||||
|
|
||||||
|
// 未到期:从原结束日次日顺延,追加天数 = 新旧结束日之差
|
||||||
|
oldEnd := d(2026, 11, 16)
|
||||||
|
newEnd := d(2026, 12, 2)
|
||||||
|
p := PlanExtend(oldEnd, newEnd, today)
|
||||||
|
if Date(p.Base) != "2026-11-17" {
|
||||||
|
t.Fatalf("base=%s", Date(p.Base))
|
||||||
|
}
|
||||||
|
if p.Days != 16 {
|
||||||
|
t.Fatalf("days=%d want 16", p.Days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已到期:从今天起算
|
||||||
|
oldEnd = d(2026, 10, 5)
|
||||||
|
newEnd = d(2026, 11, 3)
|
||||||
|
p = PlanExtend(oldEnd, newEnd, today)
|
||||||
|
if Date(p.Base) != "2026-10-18" {
|
||||||
|
t.Fatalf("base=%s", Date(p.Base))
|
||||||
|
}
|
||||||
|
if p.Days != 17 {
|
||||||
|
t.Fatalf("days=%d want 17", p.Days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选的日期早于计算起点 → 无效
|
||||||
|
p = PlanExtend(d(2026, 11, 16), d(2026, 11, 10), today)
|
||||||
|
if !p.Backwards || p.Days != 0 {
|
||||||
|
t.Fatalf("backwards=%v days=%d", p.Backwards, p.Days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonthHelpers(t *testing.T) {
|
||||||
|
if got := Date(MonthEnd(2026, time.February)); got != "2026-02-28" {
|
||||||
|
t.Fatalf("MonthEnd=%s", got)
|
||||||
|
}
|
||||||
|
if got := Date(MonthEnd(2024, time.February)); got != "2024-02-29" {
|
||||||
|
t.Fatalf("MonthEnd=%s", got)
|
||||||
|
}
|
||||||
|
if got := WeekdayIndex(d(2026, 10, 18)); got != 6 { // 周日
|
||||||
|
t.Fatalf("WeekdayIndex=%d want 6", got)
|
||||||
|
}
|
||||||
|
if got := WeekdayIndex(d(2026, 10, 19)); got != 0 { // 周一
|
||||||
|
t.Fatalf("WeekdayIndex=%d want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Package logx 提供「控制台 + 按天切分文件」的运行日志。
|
||||||
|
package logx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dailyWriter struct {
|
||||||
|
dir string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
f *os.File
|
||||||
|
day string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dailyWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
day := time.Now().Format("2006-01-02")
|
||||||
|
if w.f == nil || w.day != day {
|
||||||
|
if w.f != nil {
|
||||||
|
_ = w.f.Close()
|
||||||
|
w.f = nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(w.dir, 0o755); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(filepath.Join(w.dir, "daili-"+day+".log"),
|
||||||
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
w.f = f
|
||||||
|
w.day = day
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写文件失败不能影响控制台输出
|
||||||
|
n, err := w.f.Write(p)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dailyWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := w.f.Close()
|
||||||
|
w.f = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup 把标准日志同时写到控制台与 dir 下按天切分的文件。
|
||||||
|
// 返回的 Closer 在进程退出前关闭即可。
|
||||||
|
func Setup(dir string) (io.Closer, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := &dailyWriter{dir: dir}
|
||||||
|
// 预热:启动即创建当天文件,避免“刚启动就崩、日志却没落盘”
|
||||||
|
if _, err := w.Write(nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stderr, w))
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package logx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupWritesFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
logDir := filepath.Join(dir, "logs")
|
||||||
|
|
||||||
|
closer, err := Setup(logDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Setup 失败: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("hello-logx-%d", 42)
|
||||||
|
if err := closer.Close(); err != nil {
|
||||||
|
t.Fatalf("关闭失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(logDir)
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("日志目录应有 1 个文件, 实际 %d: %v", len(entries), err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(logDir, entries[0].Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "hello-logx-42") {
|
||||||
|
t.Fatalf("日志未落盘,文件内容: %q", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupCreatesFileImmediately(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
closer, err := Setup(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer closer.Close()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("Setup 后应立刻存在当天日志文件: %v %d", err, len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Package pw 提供口令散列(PBKDF2-HMAC-SHA256),仅用标准库。
|
||||||
|
package pw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const iterations = 120000
|
||||||
|
|
||||||
|
// Hash 生成 `pbkdf2-sha256$迭代次数$盐$摘要` 形式的字符串。
|
||||||
|
func Hash(password string) (string, error) {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dk := pbkdf2Key([]byte(password), salt, iterations, 32)
|
||||||
|
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
|
||||||
|
iterations,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(dk)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 校验口令,常量时间比较。
|
||||||
|
func Verify(password, encoded string) bool {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
iter, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil || iter <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil || len(want) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := pbkdf2Key([]byte(password), salt, iter, len(want))
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func pbkdf2Key(password, salt []byte, iter, keyLen int) []byte {
|
||||||
|
prf := hmac.New(sha256.New, password)
|
||||||
|
hashLen := prf.Size()
|
||||||
|
numBlocks := (keyLen + hashLen - 1) / hashLen
|
||||||
|
var buf [4]byte
|
||||||
|
dk := make([]byte, 0, numBlocks*hashLen)
|
||||||
|
u := make([]byte, hashLen)
|
||||||
|
for block := 1; block <= numBlocks; block++ {
|
||||||
|
prf.Reset()
|
||||||
|
prf.Write(salt)
|
||||||
|
buf[0] = byte(block >> 24)
|
||||||
|
buf[1] = byte(block >> 16)
|
||||||
|
buf[2] = byte(block >> 8)
|
||||||
|
buf[3] = byte(block)
|
||||||
|
prf.Write(buf[:4])
|
||||||
|
dk = prf.Sum(dk)
|
||||||
|
t := dk[len(dk)-hashLen:]
|
||||||
|
copy(u, t)
|
||||||
|
for n := 2; n <= iter; n++ {
|
||||||
|
prf.Reset()
|
||||||
|
prf.Write(u)
|
||||||
|
u = u[:0]
|
||||||
|
u = prf.Sum(u)
|
||||||
|
for x := range u {
|
||||||
|
t[x] ^= u[x]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dk[:keyLen]
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetBackupDir 设置备份目录(来自配置文件)。
|
||||||
|
func (db *DB) SetBackupDir(dir string) { db.BackupPath = dir }
|
||||||
|
|
||||||
|
// BackupDir 返回备份目录。
|
||||||
|
func (db *DB) BackupDir() string {
|
||||||
|
if db.BackupPath != "" {
|
||||||
|
return db.BackupPath
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(db.Path), "backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupDaily 当天首次调用时生成一份备份;已有今日备份则跳过。
|
||||||
|
// 空库(还没有客户)不生成备份,避免第一天的备份是空壳。
|
||||||
|
func (db *DB) BackupDaily(keep int) (string, error) {
|
||||||
|
dir := db.BackupDir()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
prefix := "daili-" + now.Format("20060102") + "-"
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasPrefix(e.Name(), prefix) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, err := db.CustomerCount(); err != nil || n == 0 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", now.Format("20060102-150405")))
|
||||||
|
return db.BackupTo(target, keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupNow 立即生成一份备份。
|
||||||
|
func (db *DB) BackupNow(keep int) (string, error) {
|
||||||
|
dir := db.BackupDir()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", time.Now().Format("20060102-150405")))
|
||||||
|
return db.BackupTo(target, keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupTo 用 VACUUM INTO 生成一致性备份,并按保留份数清理。
|
||||||
|
func (db *DB) BackupTo(target string, keep int) (string, error) {
|
||||||
|
abs, err := filepath.Abs(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("创建备份目录失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(abs); err == nil {
|
||||||
|
return abs, fmt.Errorf("备份文件已存在: %s", filepath.Base(abs))
|
||||||
|
}
|
||||||
|
escaped := strings.ReplaceAll(strings.ReplaceAll(abs, "\\", "/"), "'", "''")
|
||||||
|
if _, err := db.sql.Exec("VACUUM INTO '" + escaped + "'"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if keep > 0 {
|
||||||
|
_ = db.pruneBackups(keep)
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBackups 列出备份(新→旧)。
|
||||||
|
func (db *DB) ListBackups() ([]string, error) {
|
||||||
|
dir := db.BackupDir()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".db") {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Sort(sort.Reverse(sort.StringSlice(out)))
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) pruneBackups(keep int) error {
|
||||||
|
list, err := db.ListBackups()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(list) <= keep {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := db.BackupDir()
|
||||||
|
for _, name := range list[keep:] {
|
||||||
|
_ = os.Remove(filepath.Join(dir, name))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Checkin 每日服务打卡。
|
||||||
|
type Checkin struct {
|
||||||
|
ID int64
|
||||||
|
SubID int64
|
||||||
|
ServiceDate string
|
||||||
|
Status string
|
||||||
|
Note string
|
||||||
|
OperatorID int64
|
||||||
|
OperatorName string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCheckin 打卡(同订阅同日覆盖),按次套餐完成时扣次。
|
||||||
|
func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error {
|
||||||
|
if status != "done" && status != "skipped" {
|
||||||
|
return errors.New("打卡状态不正确")
|
||||||
|
}
|
||||||
|
if _, err := domain.ParseDate(date); err != nil {
|
||||||
|
return errors.New("日期格式不正确")
|
||||||
|
}
|
||||||
|
return db.withTx(func(tx *sql.Tx) error {
|
||||||
|
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Status == domain.StatusClosed {
|
||||||
|
return errors.New("订阅已结束,无法打卡")
|
||||||
|
}
|
||||||
|
var oldStatus string
|
||||||
|
err = tx.QueryRow(`SELECT status FROM checkins WHERE sub_id=? AND service_date=?`, subID, date).Scan(&oldStatus)
|
||||||
|
hasOld := err == nil
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := 0
|
||||||
|
if s.Unit == domain.UnitCount {
|
||||||
|
oldDone := hasOld && oldStatus == "done"
|
||||||
|
newDone := status == "done"
|
||||||
|
switch {
|
||||||
|
case newDone && !oldDone:
|
||||||
|
if s.UsedCount+1 > s.TotalCount {
|
||||||
|
return errors.New("剩余次数不足,请先追加次数")
|
||||||
|
}
|
||||||
|
delta = 1
|
||||||
|
case !newDone && oldDone:
|
||||||
|
delta = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := nowStamp()
|
||||||
|
if hasOld {
|
||||||
|
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。
|
||||||
|
func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
|
||||||
|
var subID int64
|
||||||
|
var status string
|
||||||
|
var date string
|
||||||
|
err := db.withTx(func(tx *sql.Tx) error {
|
||||||
|
if err := tx.QueryRow(`SELECT sub_id,status,service_date FROM checkins WHERE id=?`, id).
|
||||||
|
Scan(&subID, &status, &date); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id); err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckinsBetween 取区间内打卡,索引为 [subID][date]。
|
||||||
|
func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, error) {
|
||||||
|
out := map[int64]map[string]Checkin{}
|
||||||
|
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
|
||||||
|
IFNULL(u.display_name,''),c.created_at,c.updated_at
|
||||||
|
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
|
||||||
|
WHERE c.service_date BETWEEN ? AND ?`, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var c Checkin
|
||||||
|
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
|
||||||
|
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out[c.SubID] == nil {
|
||||||
|
out[c.SubID] = map[string]Checkin{}
|
||||||
|
}
|
||||||
|
out[c.SubID][c.ServiceDate] = c
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckinsForSub 某订阅的打卡历史。
|
||||||
|
func (db *DB) CheckinsForSub(subID int64, limit int) ([]Checkin, error) {
|
||||||
|
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
|
||||||
|
IFNULL(u.display_name,''),c.created_at,c.updated_at
|
||||||
|
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
|
||||||
|
WHERE c.sub_id=? ORDER BY c.service_date DESC LIMIT ?`, subID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Checkin{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Checkin
|
||||||
|
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
|
||||||
|
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckinFor 取某订阅某天的打卡。
|
||||||
|
func (db *DB) CheckinFor(subID int64, date string) (*Checkin, error) {
|
||||||
|
var c Checkin
|
||||||
|
err := db.sql.QueryRow(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
|
||||||
|
IFNULL(u.display_name,''),c.created_at,c.updated_at
|
||||||
|
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
|
||||||
|
WHERE c.sub_id=? AND c.service_date=?`, subID, date).
|
||||||
|
Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID, &c.OperatorName, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Customer 客户。
|
||||||
|
type Customer struct {
|
||||||
|
ID int64
|
||||||
|
Tail string
|
||||||
|
Name string
|
||||||
|
GameName string
|
||||||
|
GameServer string
|
||||||
|
Contact string
|
||||||
|
Tags string
|
||||||
|
Source string
|
||||||
|
Note string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
|
||||||
|
// 列表聚合
|
||||||
|
SubCount int
|
||||||
|
Summary string
|
||||||
|
Level string // ok | warn | today | expired | none
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagList 拆分标签。
|
||||||
|
func (c *Customer) TagList() []string {
|
||||||
|
var out []string
|
||||||
|
for _, t := range strings.Split(c.Tags, ",") {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at`
|
||||||
|
|
||||||
|
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
|
||||||
|
var c Customer
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCustomer 新建客户(尾号+姓名唯一)。
|
||||||
|
func (db *DB) CreateCustomer(c *Customer) (int64, error) {
|
||||||
|
now := nowStamp()
|
||||||
|
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
|
||||||
|
c.Tags, c.Source, c.Note, now, now)
|
||||||
|
if err != nil {
|
||||||
|
if isUnique(err) {
|
||||||
|
return 0, ErrDuplicate
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCustomer 更新客户。
|
||||||
|
func (db *DB) UpdateCustomer(c *Customer) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
|
||||||
|
c.Tags, c.Source, c.Note, nowStamp(), c.ID)
|
||||||
|
if err != nil && isUnique(err) {
|
||||||
|
return ErrDuplicate
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer 取单个客户。
|
||||||
|
func (db *DB) Customer(id int64) (*Customer, error) {
|
||||||
|
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE id=?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCustomers 分页查询客户,并聚合订阅摘要。
|
||||||
|
func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int) ([]Customer, int, error) {
|
||||||
|
where := "1=1"
|
||||||
|
args := []any{}
|
||||||
|
if q = strings.TrimSpace(q); q != "" {
|
||||||
|
like := "%" + q + "%"
|
||||||
|
where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)"
|
||||||
|
args = append(args, like, like, like, like, like)
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE `+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := db.sql.Query(`SELECT `+customerCols+` FROM customers WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||||
|
append(args, limit, offset)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Customer{}
|
||||||
|
ids := []int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanCustomer(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, *c)
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
subs, err := db.SubsByCustomers(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
byCustomer := map[int64][]Subscription{}
|
||||||
|
for _, s := range subs {
|
||||||
|
byCustomer[s.CustomerID] = append(byCustomer[s.CustomerID], s)
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
list := byCustomer[out[i].ID]
|
||||||
|
out[i].SubCount = len(list)
|
||||||
|
out[i].Summary, out[i].Level = summarizeSubs(list, today, warnDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。
|
||||||
|
func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) {
|
||||||
|
if len(subs) == 0 {
|
||||||
|
return "暂无订阅", domain.LevelNone
|
||||||
|
}
|
||||||
|
t := domain.ParseDateOr(today, domain.Today())
|
||||||
|
rank := map[string]int{domain.LevelExpired: 4, domain.LevelToday: 3, domain.LevelWarn: 2, domain.LevelOK: 1, domain.LevelNone: 0}
|
||||||
|
worst := domain.LevelNone
|
||||||
|
parts := []string{}
|
||||||
|
for _, s := range subs {
|
||||||
|
e := s.Eval(t, warnDays)
|
||||||
|
if e.State == domain.StateClosed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if s.Unit == domain.UnitDay {
|
||||||
|
switch {
|
||||||
|
case e.State == domain.StateExpired:
|
||||||
|
text = s.PackageName + " 已过期"
|
||||||
|
case e.DaysLeft == 0:
|
||||||
|
text = s.PackageName + " 今天到期"
|
||||||
|
default:
|
||||||
|
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)
|
||||||
|
if rank[e.Level] > rank[worst] {
|
||||||
|
worst = e.Level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "仅历史订阅", domain.LevelNone
|
||||||
|
}
|
||||||
|
if len(parts) > 2 {
|
||||||
|
parts = append(parts[:2], fmt.Sprintf("等%d项", len(parts)))
|
||||||
|
}
|
||||||
|
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 客户总数。
|
||||||
|
func (db *DB) CustomerCount() (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Package store 负责 SQLite 连接、迁移与全部数据读写。
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 常见错误
|
||||||
|
var (
|
||||||
|
ErrDuplicate = errors.New("记录已存在")
|
||||||
|
ErrNotFound = errors.New("记录不存在")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB 是数据库句柄。
|
||||||
|
type DB struct {
|
||||||
|
sql *sql.DB
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// BackupPath 由配置文件指定;为空时回落到数据库同级的 backup/ 目录。
|
||||||
|
BackupPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open 打开或创建数据库文件。
|
||||||
|
//
|
||||||
|
// 采用 DELETE 回滚日志模式而不是 WAL:这样全部数据始终在 daili.db 这一个文件里,
|
||||||
|
// 运行中不会产生 -wal/-shm 旁路文件,用户复制一个 daili.db 就能无损迁移;
|
||||||
|
// 删掉 daili.db 也就等于清空全部数据。
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
if abs, err := filepath.Abs(path); err == nil {
|
||||||
|
path = abs
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建数据目录失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 数据库文件不存在(用户删除 = 重置)时,清掉可能是旧版本残留的旁路文件,
|
||||||
|
// 避免一个孤立的 -wal 干扰新建的库。
|
||||||
|
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
_ = os.Remove(path + "-wal")
|
||||||
|
_ = os.Remove(path + "-shm")
|
||||||
|
}
|
||||||
|
dsn := strings.ReplaceAll(path, "\\", "/") +
|
||||||
|
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=synchronous(FULL)"
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
sqlDB.SetMaxIdleConns(1)
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 兜底确认:旧版本的 WAL 库在打开时会被自动 checkpoint 并切回 DELETE 模式
|
||||||
|
var mode string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA journal_mode").Scan(&mode); err == nil && !strings.EqualFold(mode, "delete") {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("数据库日志模式切换失败(当前 %s),请确认 daili.db 没有被其他程序占用", mode)
|
||||||
|
}
|
||||||
|
return &DB{sql: sqlDB, Path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalMode 返回当前日志模式(正常应为 delete)。
|
||||||
|
func (db *DB) JournalMode() (string, error) {
|
||||||
|
var mode string
|
||||||
|
err := db.sql.QueryRow("PRAGMA journal_mode").Scan(&mode)
|
||||||
|
return mode, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭数据库。
|
||||||
|
func (db *DB) Close() error { return db.sql.Close() }
|
||||||
|
|
||||||
|
// SQL 暴露底层连接(供备份等使用)。
|
||||||
|
func (db *DB) SQL() *sql.DB { return db.sql }
|
||||||
|
|
||||||
|
func nowStamp() string { return time.Now().Format(domain.StampLayout) }
|
||||||
|
|
||||||
|
func (db *DB) withTx(fn func(*sql.Tx) error) error {
|
||||||
|
tx, err := db.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUnique(err error) bool {
|
||||||
|
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 幂等建表。
|
||||||
|
func (db *DB) Migrate() error {
|
||||||
|
for _, stmt := range schemaStatements {
|
||||||
|
if _, err := db.sql.Exec(stmt); err != nil {
|
||||||
|
return fmt.Errorf("迁移失败: %w\nSQL: %s", err, stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaStatements = []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
ip TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS 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 '#2F5D50',
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tail TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
game_name TEXT NOT NULL DEFAULT '',
|
||||||
|
game_server TEXT NOT NULL DEFAULT '',
|
||||||
|
contact TEXT NOT NULL DEFAULT '',
|
||||||
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
|
source TEXT NOT NULL DEFAULT '',
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (tail, name)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
package_id INTEGER NOT NULL REFERENCES packages(id),
|
||||||
|
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,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
closed_at TEXT,
|
||||||
|
UNIQUE (customer_id, package_id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sub_pauses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
|
start_at TEXT NOT NULL,
|
||||||
|
end_at TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sub_adjustments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
base_date TEXT,
|
||||||
|
old_end_date TEXT,
|
||||||
|
new_end_date TEXT,
|
||||||
|
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 '',
|
||||||
|
operator_id INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS checkins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
|
service_date TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('done','skipped')),
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
operator_id INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (sub_id, service_date)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS op_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
operator_id INTEGER REFERENCES users(id),
|
||||||
|
operator_name TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity TEXT NOT NULL DEFAULT '',
|
||||||
|
entity_id INTEGER,
|
||||||
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_sub_status_end ON subscriptions(status, end_date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_sub_customer ON subscriptions(customer_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_sub_package ON subscriptions(package_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_cust_tail ON customers(tail)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_adj_sub ON sub_adjustments(sub_id, created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ci_date ON checkins(service_date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ci_sub ON checkins(sub_id, service_date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_pause_sub ON sub_pauses(sub_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_log_created ON op_logs(created_at)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 设置 ----------
|
||||||
|
|
||||||
|
// Setting 读取设置,缺失时返回默认值。
|
||||||
|
func (db *DB) Setting(key, def string) string {
|
||||||
|
var v string
|
||||||
|
err := db.sql.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
|
||||||
|
if err != nil || v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSetting 写入设置。
|
||||||
|
func (db *DB) SetSetting(key, value string) error {
|
||||||
|
_, err := db.sql.Exec(`INSERT INTO settings(key,value) VALUES(?,?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportRowSet 是导出的表头与数据行。
|
||||||
|
type ExportRowSet struct {
|
||||||
|
Header []string
|
||||||
|
Rows [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCustomers 导出客户。
|
||||||
|
func (db *DB) ExportCustomers() (*ExportRowSet, error) {
|
||||||
|
rows, err := db.sql.Query(`SELECT id,tail,name,game_name,game_server,contact,tags,source,note,created_at FROM customers ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := &ExportRowSet{Header: []string{"ID", "手机尾号", "客户名", "游戏名", "区服/UID", "联系方式", "标签", "来源", "备注", "创建时间"}}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var tail, name, game, server, contact, tags, source, note, created string
|
||||||
|
if err := rows.Scan(&id, &tail, &name, &game, &server, &contact, &tags, &source, ¬e, &created); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Rows = append(out.Rows, []string{fmt.Sprint(id), tail, name, game, server, contact, tags, source, note, created})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportSubscriptions 导出订阅(含派生剩余量)。
|
||||||
|
func (db *DB) ExportSubscriptions(today time.Time, warnDays int) (*ExportRowSet, error) {
|
||||||
|
subs, _, err := db.ListSubs(SubFilter{Today: today, Limit: 100000})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日/有效期",
|
||||||
|
"总次数", "已用", "剩余", "原文状态", "备注", "更新时间"}}
|
||||||
|
stateLabel := map[string]string{
|
||||||
|
domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中",
|
||||||
|
domain.StateExpired: "已到期", domain.StateUsedUp: "次数用尽", domain.StateClosed: "已结束",
|
||||||
|
}
|
||||||
|
for _, s := range subs {
|
||||||
|
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{
|
||||||
|
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,
|
||||||
|
fmt.Sprint(s.TotalCount), fmt.Sprint(s.UsedCount), remaining, s.Status, s.Note, s.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportLogs 导出操作日志。
|
||||||
|
func (db *DB) ExportLogs() (*ExportRowSet, error) {
|
||||||
|
rows, err := db.sql.Query(`SELECT created_at,operator_name,action,entity,summary,ip FROM op_logs ORDER BY id DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := &ExportRowSet{Header: []string{"时间", "操作人", "动作", "对象", "摘要", "IP"}}
|
||||||
|
for rows.Next() {
|
||||||
|
var created, operator, action, entity, summary, ip string
|
||||||
|
if err := rows.Scan(&created, &operator, &action, &entity, &summary, &ip); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Rows = append(out.Rows, []string{created, operator, action, entity, summary, ip})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpLog 操作日志。
|
||||||
|
type OpLog struct {
|
||||||
|
ID int64
|
||||||
|
OperatorID int64
|
||||||
|
OperatorName string
|
||||||
|
Action string
|
||||||
|
Entity string
|
||||||
|
EntityID int64
|
||||||
|
Summary string
|
||||||
|
Detail string
|
||||||
|
IP string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log 写一条操作日志。
|
||||||
|
func (db *DB) Log(l OpLog) error {
|
||||||
|
_, err := db.sql.Exec(`INSERT INTO op_logs(operator_id,operator_name,action,entity,entity_id,summary,detail,ip,created_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||||
|
nullIfZero(l.OperatorID), l.OperatorName, l.Action, l.Entity, nullIfZero(l.EntityID),
|
||||||
|
l.Summary, l.Detail, l.IP, nowStamp())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfZero(v int64) any {
|
||||||
|
if v == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLogs 分页查询日志。
|
||||||
|
func (db *DB) ListLogs(q string, limit, offset int) ([]OpLog, int, error) {
|
||||||
|
where := "1=1"
|
||||||
|
args := []any{}
|
||||||
|
if q = strings.TrimSpace(q); q != "" {
|
||||||
|
like := "%" + q + "%"
|
||||||
|
where = "(summary LIKE ? OR action LIKE ? OR operator_name LIKE ? OR entity LIKE ?)"
|
||||||
|
args = append(args, like, like, like, like)
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs WHERE `+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := db.sql.Query(`SELECT id,IFNULL(operator_id,0),operator_name,action,entity,IFNULL(entity_id,0),summary,detail,ip,created_at
|
||||||
|
FROM op_logs WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, append(args, limit, offset)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []OpLog{}
|
||||||
|
for rows.Next() {
|
||||||
|
var l OpLog
|
||||||
|
if err := rows.Scan(&l.ID, &l.OperatorID, &l.OperatorName, &l.Action, &l.Entity, &l.EntityID,
|
||||||
|
&l.Summary, &l.Detail, &l.IP, &l.CreatedAt); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogCount 日志总数。
|
||||||
|
func (db *DB) LogCount() (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Package 服务套餐。
|
||||||
|
type Package struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Unit string
|
||||||
|
DefaultAmount int
|
||||||
|
PriceCents int
|
||||||
|
HasPrice bool
|
||||||
|
Color string
|
||||||
|
Note string
|
||||||
|
Archived bool
|
||||||
|
SortOrder int
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
SubCount int
|
||||||
|
ActiveCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnitLabel 返回类型中文名。
|
||||||
|
func (p *Package) UnitLabel() string {
|
||||||
|
if p.Unit == domain.UnitCount {
|
||||||
|
return "按次"
|
||||||
|
}
|
||||||
|
return "按日"
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
||||||
|
var p Package
|
||||||
|
var price sql.NullInt64
|
||||||
|
var archived int
|
||||||
|
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
||||||
|
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Archived = archived == 1
|
||||||
|
if price.Valid {
|
||||||
|
p.PriceCents = int(price.Int64)
|
||||||
|
p.HasPrice = true
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackages 列出套餐(含订阅计数)。
|
||||||
|
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
||||||
|
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.created_at,p.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
|
||||||
|
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
|
||||||
|
FROM packages p`
|
||||||
|
if !includeArchived {
|
||||||
|
q += ` WHERE p.archived=0`
|
||||||
|
}
|
||||||
|
q += ` ORDER BY p.archived, p.sort_order, p.id`
|
||||||
|
rows, err := db.sql.Query(q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Package{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Package
|
||||||
|
var price sql.NullInt64
|
||||||
|
var archived int
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
||||||
|
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Archived = archived == 1
|
||||||
|
if price.Valid {
|
||||||
|
p.PriceCents = int(price.Int64)
|
||||||
|
p.HasPrice = true
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package 取单个套餐。
|
||||||
|
func (db *DB) Package(id int64) (*Package, error) {
|
||||||
|
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at
|
||||||
|
FROM packages WHERE id=?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePackage 新建套餐。
|
||||||
|
func (db *DB) CreatePackage(p *Package) (int64, error) {
|
||||||
|
now := nowStamp()
|
||||||
|
p.Unit = normalizeUnit(p.Unit)
|
||||||
|
if p.DefaultAmount <= 0 {
|
||||||
|
p.DefaultAmount = 30
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Color) == "" {
|
||||||
|
p.Color = "#2F5D50"
|
||||||
|
}
|
||||||
|
var price any
|
||||||
|
if p.HasPrice {
|
||||||
|
price = p.PriceCents
|
||||||
|
}
|
||||||
|
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,0,?,?,?)`,
|
||||||
|
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackage 更新套餐。
|
||||||
|
func (db *DB) UpdatePackage(p *Package) error {
|
||||||
|
p.Unit = normalizeUnit(p.Unit)
|
||||||
|
if p.DefaultAmount <= 0 {
|
||||||
|
p.DefaultAmount = 30
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Color) == "" {
|
||||||
|
p.Color = "#2F5D50"
|
||||||
|
}
|
||||||
|
var price any
|
||||||
|
if p.HasPrice {
|
||||||
|
price = p.PriceCents
|
||||||
|
}
|
||||||
|
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,updated_at=?
|
||||||
|
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, nowStamp(), p.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPackageArchived 归档/取消归档。
|
||||||
|
func (db *DB) SetPackageArchived(id int64, archived bool) error {
|
||||||
|
v := 0
|
||||||
|
if archived {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
_, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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 顶栏常驻徽标。
|
||||||
|
func (db *DB) Badges(today time.Time) (dueToday, due3 int, err error) {
|
||||||
|
t := domain.Date(today)
|
||||||
|
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`, t).Scan(&dueToday); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
|
||||||
|
t, domain.Date(domain.AddDays(today, 2))).Scan(&due3)
|
||||||
|
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'`).Scan(&st.PausedSubs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`,
|
||||||
|
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 ?`,
|
||||||
|
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
|
||||||
|
WHERE status<>'closed' AND end_date BETWEEN ? AND ? GROUP BY 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 s.unit='day' AND s.end_date>=?
|
||||||
|
ORDER BY s.end_date ASC LIMIT 12`, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='count' AND s.used_count < s.total_count
|
||||||
|
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 天内到期的订阅(按到期日排序)。
|
||||||
|
func (db *DB) RecentDue(from string, days int) ([]Subscription, error) {
|
||||||
|
to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1))
|
||||||
|
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date BETWEEN ? AND ?
|
||||||
|
ORDER BY s.end_date, s.id`, from, to)
|
||||||
|
}
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) *DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatalf("迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustPackage(t *testing.T, db *DB, name, unit string, amount int) *Package {
|
||||||
|
t.Helper()
|
||||||
|
id, err := db.CreatePackage(&Package{Name: name, Unit: unit, DefaultAmount: amount, Color: "#2F5D50"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建套餐失败: %v", err)
|
||||||
|
}
|
||||||
|
p, err := db.Package(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取套餐失败: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCustomer(t *testing.T, db *DB, tail, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
id, err := db.CreateCustomer(&Customer{Tail: tail, Name: name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建客户失败: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSubAndDuplicate(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
today := domain.Today()
|
||||||
|
|
||||||
|
sub, err := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建订阅失败: %v", err)
|
||||||
|
}
|
||||||
|
if sub.EndDate != domain.Date(domain.AddDays(today, 29)) {
|
||||||
|
t.Fatalf("结束日=%s", sub.EndDate)
|
||||||
|
}
|
||||||
|
if sub.Unit != domain.UnitDay || sub.Status != domain.StatusActive {
|
||||||
|
t.Fatalf("unit=%s status=%s", sub.Unit, sub.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("同一客户同一套餐应禁止重复订阅")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始记录
|
||||||
|
adj, err := db.AdjustmentsBySub(sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取追加记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(adj) != 1 || adj[0].Kind != "initial" || adj[0].DaysAdded != 30 {
|
||||||
|
t.Fatalf("初始追加记录不正确: %+v", adj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
today := domain.Today()
|
||||||
|
|
||||||
|
sub, err := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -40)), Days: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sub.EndDate != domain.Date(domain.AddDays(today, -11)) {
|
||||||
|
t.Fatalf("end=%s", sub.EndDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
newEnd := domain.AddDays(today, 29)
|
||||||
|
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "续费", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("追加失败: %v", err)
|
||||||
|
}
|
||||||
|
if domain.Date(plan.Base) != domain.Date(today) {
|
||||||
|
t.Fatalf("已到期订阅应从今天起算,实际 %s", domain.Date(plan.Base))
|
||||||
|
}
|
||||||
|
if plan.Days != 30 {
|
||||||
|
t.Fatalf("追加天数=%d want 30", plan.Days)
|
||||||
|
}
|
||||||
|
got, _ := db.Sub(sub.ID)
|
||||||
|
if got.EndDate != domain.Date(newEnd) {
|
||||||
|
t.Fatalf("新结束日=%s", got.EndDate)
|
||||||
|
}
|
||||||
|
if got.Status != domain.StatusActive {
|
||||||
|
t.Fatalf("状态=%s", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
adj, _ := db.AdjustmentsBySub(sub.ID)
|
||||||
|
if len(adj) != 2 || adj[0].Kind != "extend_days" || adj[0].DaysAdded != 30 {
|
||||||
|
t.Fatalf("追加记录不正确: %+v", adj[0])
|
||||||
|
}
|
||||||
|
if adj[0].BaseDate != domain.Date(today) {
|
||||||
|
t.Fatalf("记录起点=%s", adj[0].BaseDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
today := domain.Today()
|
||||||
|
|
||||||
|
sub, _ := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
|
||||||
|
})
|
||||||
|
newEnd := domain.AddDays(today, 39)
|
||||||
|
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if domain.Date(plan.Base) != domain.Date(domain.AddDays(today, 30)) {
|
||||||
|
t.Fatalf("未到期应从原结束日次日起算,实际 %s", domain.Date(plan.Base))
|
||||||
|
}
|
||||||
|
if plan.Days != 10 {
|
||||||
|
t.Fatalf("追加天数=%d want 10", plan.Days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtendDaysRejectsBackwards(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
today := domain.Today()
|
||||||
|
|
||||||
|
sub, _ := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
|
||||||
|
})
|
||||||
|
if _, err := db.ExtendDays(sub.ID, domain.Date(domain.AddDays(today, 10)), "", 0); err == nil {
|
||||||
|
t.Fatal("早于计算起点的日期应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
today := domain.Today()
|
||||||
|
|
||||||
|
sub, _ := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
|
||||||
|
})
|
||||||
|
if err := db.PauseSub(sub.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := db.Sub(sub.ID)
|
||||||
|
if got.Status != domain.StatusPaused {
|
||||||
|
t.Fatalf("status=%s", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把暂停开始时间改成 5 天前,模拟暂停了 5 天
|
||||||
|
oldStart := domain.Date(domain.AddDays(today, -5)) + " 09:00:00"
|
||||||
|
if _, err := db.sql.Exec(`UPDATE sub_pauses SET start_at=? WHERE sub_id=? AND end_at IS NULL`, oldStart, sub.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shift, err := db.ResumeSub(sub.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if shift != 5 {
|
||||||
|
t.Fatalf("顺延天数=%d want 5", shift)
|
||||||
|
}
|
||||||
|
got, _ = db.Sub(sub.ID)
|
||||||
|
if got.EndDate != domain.Date(domain.AddDays(today, 34)) {
|
||||||
|
t.Fatalf("end=%s want %s", got.EndDate, domain.Date(domain.AddDays(today, 34)))
|
||||||
|
}
|
||||||
|
if got.Status != domain.StatusActive {
|
||||||
|
t.Fatalf("status=%s", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
path, err := db.BackupNow(14)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("备份失败: %v", err)
|
||||||
|
}
|
||||||
|
list, err := db.ListBackups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("备份数量=%d want 1 (%s)", len(list), path)
|
||||||
|
}
|
||||||
|
// 同日再备份:文件名按秒生成,允许重复
|
||||||
|
if _, err := db.BackupNow(1); err != nil {
|
||||||
|
// 同一秒内会重名,算作已存在,不视为失败
|
||||||
|
t.Logf("第二次备份返回: %v", err)
|
||||||
|
}
|
||||||
|
list, _ = db.ListBackups()
|
||||||
|
if len(list) > 1 {
|
||||||
|
t.Fatalf("保留份数未生效: %d", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsAndBadges(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
_ = db.SetSetting("warn_days", "5")
|
||||||
|
if got := db.Setting("warn_days", "3"); got != "5" {
|
||||||
|
t.Fatalf("setting=%s", got)
|
||||||
|
}
|
||||||
|
if got := db.Setting("missing", "def"); got != "def" {
|
||||||
|
t.Fatalf("默认值=%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
today := domain.Today()
|
||||||
|
_, _ = db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -30)), Days: 31,
|
||||||
|
})
|
||||||
|
due, due3, err := db.Badges(today)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if due != 1 || due3 != 1 {
|
||||||
|
t.Fatalf("due=%d due3=%d want 1/1", due, due3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchByTail(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
mustCustomer(t, db, "8899", "阿飞")
|
||||||
|
mustCustomer(t, db, "8890", "阿飞二号")
|
||||||
|
today := domain.Today()
|
||||||
|
list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(list) != 1 || list[0].Name != "阿飞" {
|
||||||
|
t.Fatalf("搜索命中 %d 条", total)
|
||||||
|
}
|
||||||
|
list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3)
|
||||||
|
if total != 2 {
|
||||||
|
t.Fatalf("前缀搜索应命中 2 条,实际 %d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniqueCustomer(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
mustCustomer(t, db, "1234", "老张")
|
||||||
|
_, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "老张"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("重复的尾号+姓名应被拒绝")
|
||||||
|
}
|
||||||
|
if _, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "另一位"}); err != nil {
|
||||||
|
t.Fatalf("同尾号不同姓名应允许: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
dir := db.BackupDir()
|
||||||
|
names := []string{
|
||||||
|
"daili-20260101-000001.db",
|
||||||
|
"daili-20260102-000001.db",
|
||||||
|
"daili-20260103-000001.db",
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if _, err := db.BackupTo(filepath.Join(dir, n), 0); err != nil {
|
||||||
|
t.Fatalf("备份失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.pruneBackups(2); err != nil {
|
||||||
|
t.Fatalf("清理失败: %v", err)
|
||||||
|
}
|
||||||
|
list, err := db.ListBackups()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("保留 2 份,实际 %d 份: %v", len(list), list)
|
||||||
|
}
|
||||||
|
if list[0] != names[2] || list[1] != names[1] {
|
||||||
|
t.Fatalf("保留的不是最新两份: %v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDirOverride(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
custom := filepath.Join(t.TempDir(), "自定义备份")
|
||||||
|
db.SetBackupDir(custom)
|
||||||
|
if db.BackupDir() != custom {
|
||||||
|
t.Fatalf("备份目录未生效: %s", db.BackupDir())
|
||||||
|
}
|
||||||
|
if _, err := db.BackupNow(5); err != nil {
|
||||||
|
t.Fatalf("备份失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(custom); err != nil {
|
||||||
|
t.Fatalf("备份目录未创建: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile 用于模拟“用户只拷贝数据库文件”。
|
||||||
|
func copyFile(t *testing.T, src, dst string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 %s 失败: %v", src, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
t.Fatalf("写入 %s 失败: %v", dst, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleFileDatabase(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "daili.db")
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mode, err := db.JournalMode(); err != nil || !strings.EqualFold(mode, "delete") {
|
||||||
|
t.Fatalf("journal_mode=%s err=%v", mode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
if _, err := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"-wal", "-shm"} {
|
||||||
|
if _, err := os.Stat(path + suffix); err == nil {
|
||||||
|
t.Fatalf("运行中不应出现 %s", suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
||||||
|
if _, err := os.Stat(path + suffix); err == nil {
|
||||||
|
t.Fatalf("关闭后不应残留 %s", suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(path); err != nil || fi.Size() < 8192 {
|
||||||
|
t.Fatalf("主库大小异常: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyDatabaseIsLossless(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
srcPath := filepath.Join(srcDir, "daili.db")
|
||||||
|
db, err := Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
|
||||||
|
cid := mustCustomer(t, db, "1234", "老张")
|
||||||
|
sub, err := db.CreateSub(CreateSubInput{
|
||||||
|
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SaveCheckin(sub.ID, domain.Date(domain.Today()), "done", "迁移前打卡", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只拷贝 daili.db 一个文件
|
||||||
|
dstDir := t.TempDir()
|
||||||
|
dstPath := filepath.Join(dstDir, "daili.db")
|
||||||
|
copyFile(t, srcPath, dstPath)
|
||||||
|
|
||||||
|
cp, err := Open(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开副本失败: %v", err)
|
||||||
|
}
|
||||||
|
defer cp.Close()
|
||||||
|
if n, err := cp.CustomerCount(); err != nil || n != 1 {
|
||||||
|
t.Fatalf("客户数=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
got, err := cp.Sub(sub.ID)
|
||||||
|
if err != nil || got.CustomerTail != "1234" || got.EndDate != sub.EndDate {
|
||||||
|
t.Fatalf("订阅丢失: %+v err=%v", got, err)
|
||||||
|
}
|
||||||
|
ci, err := cp.CheckinFor(sub.ID, domain.Date(domain.Today()))
|
||||||
|
if err != nil || ci.Note != "迁移前打卡" {
|
||||||
|
t.Fatalf("打卡记录丢失: %+v err=%v", ci, err)
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"-wal", "-shm"} {
|
||||||
|
if _, err := os.Stat(dstPath + suffix); err == nil {
|
||||||
|
t.Fatalf("副本目录出现了 %s", suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteDatabaseResets(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "daili.db")
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustCustomer(t, db, "1234", "老张")
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range []string{path, path + "-wal", path + "-shm", path + "-journal"} {
|
||||||
|
_ = os.Remove(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("删除后重建失败: %v", err)
|
||||||
|
}
|
||||||
|
defer fresh.Close()
|
||||||
|
if err := fresh.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, _ := fresh.CustomerCount(); n != 0 {
|
||||||
|
t.Fatalf("删库后应无客户,实际 %d", n)
|
||||||
|
}
|
||||||
|
if n, _ := fresh.CountUsers(); n != 0 {
|
||||||
|
t.Fatalf("删库后应无管理员,实际 %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleSidecarsCleanup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "daili.db")
|
||||||
|
// 模拟“删了主库但留下旧版本 WAL”的情况
|
||||||
|
if err := os.WriteFile(path+"-wal", []byte("stale"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path+"-shm", []byte("stale"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开失败: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
for _, suffix := range []string{"-wal", "-shm"} {
|
||||||
|
if _, err := os.Stat(path + suffix); err == nil {
|
||||||
|
t.Fatalf("残留的 %s 应被清理", suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "legacy.db")
|
||||||
|
|
||||||
|
// 模拟 v1.1 的 WAL 库
|
||||||
|
dsn := strings.ReplaceAll(path, "\\", "/") + "?_pragma=journal_mode(WAL)"
|
||||||
|
old, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := old.Exec(`CREATE TABLE legacy(x INTEGER)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := old.Exec(`INSERT INTO legacy VALUES(42)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := old.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开旧库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
var x int
|
||||||
|
if err := db.sql.QueryRow(`SELECT x FROM legacy`).Scan(&x); err != nil || x != 42 {
|
||||||
|
t.Fatalf("旧库数据丢失: x=%d err=%v", x, err)
|
||||||
|
}
|
||||||
|
if mode, _ := db.JournalMode(); !strings.EqualFold(mode, "delete") {
|
||||||
|
t.Fatalf("旧库未切到单文件模式: %s", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscription 服务订阅。
|
||||||
|
type Subscription struct {
|
||||||
|
ID int64
|
||||||
|
CustomerID int64
|
||||||
|
PackageID int64
|
||||||
|
Unit string
|
||||||
|
Status string
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
TotalCount int
|
||||||
|
UsedCount int
|
||||||
|
Note string
|
||||||
|
CreatedBy int64
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
ClosedAt string
|
||||||
|
|
||||||
|
// 联表
|
||||||
|
PackageName string
|
||||||
|
PackageColor string
|
||||||
|
CustomerTail string
|
||||||
|
CustomerName string
|
||||||
|
CustomerContact string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCount 是否按次套餐。
|
||||||
|
func (s *Subscription) IsCount() bool { return s.Unit == domain.UnitCount }
|
||||||
|
|
||||||
|
// Pause 暂停分段。
|
||||||
|
type Pause struct {
|
||||||
|
ID int64
|
||||||
|
SubID int64
|
||||||
|
StartAt string
|
||||||
|
EndAt string
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjustment 追加记录。
|
||||||
|
type Adjustment struct {
|
||||||
|
ID int64
|
||||||
|
SubID int64
|
||||||
|
Kind string
|
||||||
|
BaseDate string
|
||||||
|
OldEndDate string
|
||||||
|
NewEndDate string
|
||||||
|
DaysAdded int
|
||||||
|
CountAdded int
|
||||||
|
OldTotal int
|
||||||
|
NewTotal int
|
||||||
|
Note string
|
||||||
|
OperatorID int64
|
||||||
|
OperatorName string
|
||||||
|
CreatedAt string
|
||||||
|
|
||||||
|
// 联表
|
||||||
|
CustomerTail string
|
||||||
|
CustomerName string
|
||||||
|
PackageName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindLabel 返回追加类型的中文名。
|
||||||
|
func (a *Adjustment) KindLabel() string {
|
||||||
|
switch a.Kind {
|
||||||
|
case "initial":
|
||||||
|
return "开通"
|
||||||
|
case "extend_days":
|
||||||
|
return "追加天数"
|
||||||
|
case "add_count":
|
||||||
|
return "追加次数"
|
||||||
|
case "set_end_date":
|
||||||
|
return "调整结束日"
|
||||||
|
}
|
||||||
|
return "调整"
|
||||||
|
}
|
||||||
|
|
||||||
|
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''),
|
||||||
|
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
|
||||||
|
p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
|
||||||
|
FROM subscriptions s
|
||||||
|
JOIN packages p ON p.id=s.package_id
|
||||||
|
JOIN customers c ON c.id=s.customer_id`
|
||||||
|
|
||||||
|
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
|
||||||
|
var s Subscription
|
||||||
|
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
|
||||||
|
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
|
||||||
|
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub 取单个订阅。
|
||||||
|
func (db *DB) Sub(id int64) (*Subscription, error) {
|
||||||
|
return scanSub(db.sql.QueryRow(subSelect+` WHERE s.id=?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubsByCustomer 取某客户的订阅。
|
||||||
|
func (db *DB) SubsByCustomer(customerID int64) ([]Subscription, error) {
|
||||||
|
return db.querySubs(subSelect+` WHERE s.customer_id=? ORDER BY s.status, s.id`, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubsByCustomers 批量取订阅(客户列表聚合用)。
|
||||||
|
func (db *DB) SubsByCustomers(ids []int64) ([]Subscription, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ph := placeholders(len(ids))
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
return db.querySubs(subSelect+` WHERE s.customer_id IN (`+ph+`) ORDER BY s.id`, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) querySubs(q string, args ...any) ([]Subscription, error) {
|
||||||
|
rows, err := db.sql.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Subscription{}
|
||||||
|
for rows.Next() {
|
||||||
|
s, err := scanSub(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubFilter 订阅列表筛选。
|
||||||
|
type SubFilter struct {
|
||||||
|
Q string
|
||||||
|
State string
|
||||||
|
PackageID int64
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
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 < ?))))`
|
||||||
|
|
||||||
|
// ListSubs 分页查询订阅。
|
||||||
|
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
|
||||||
|
where := []string{"1=1"}
|
||||||
|
args := []any{}
|
||||||
|
today := domain.Date(f.Today)
|
||||||
|
if q := strings.TrimSpace(f.Q); q != "" {
|
||||||
|
like := "%" + q + "%"
|
||||||
|
where = append(where, "(c.tail LIKE ? OR c.name LIKE ? OR p.name LIKE ?)")
|
||||||
|
args = append(args, like, like, like)
|
||||||
|
}
|
||||||
|
if f.PackageID > 0 {
|
||||||
|
where = append(where, "s.package_id=?")
|
||||||
|
args = append(args, f.PackageID)
|
||||||
|
}
|
||||||
|
switch f.State {
|
||||||
|
case "active":
|
||||||
|
where = append(where, "s.status='active'", "NOT "+expiredExpr)
|
||||||
|
args = append(args, today, today)
|
||||||
|
case "paused":
|
||||||
|
where = append(where, "s.status='paused'")
|
||||||
|
case "closed":
|
||||||
|
where = append(where, "s.status='closed'")
|
||||||
|
case "expired":
|
||||||
|
where = append(where, "s.status<>'closed'", expiredExpr)
|
||||||
|
args = append(args, today, today)
|
||||||
|
case "due7":
|
||||||
|
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)))
|
||||||
|
case "due3":
|
||||||
|
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, 2)))
|
||||||
|
case "due_today":
|
||||||
|
where = append(where, "s.status<>'closed'", "s.end_date = ?")
|
||||||
|
args = append(args, today)
|
||||||
|
}
|
||||||
|
w := strings.Join(where, " AND ")
|
||||||
|
var total int
|
||||||
|
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s
|
||||||
|
JOIN packages p ON p.id=s.package_id JOIN customers c ON c.id=s.customer_id WHERE `+w, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
lim := f.Limit
|
||||||
|
if lim <= 0 {
|
||||||
|
lim = 30
|
||||||
|
}
|
||||||
|
q := subSelect + ` WHERE ` + w + ` ORDER BY s.status, IFNULL(s.end_date,'9999-12-31'), s.id DESC LIMIT ? OFFSET ?`
|
||||||
|
subs, err := db.querySubs(q, append(args, lim, f.Offset)...)
|
||||||
|
return subs, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。
|
||||||
|
func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) {
|
||||||
|
return db.querySubs(subSelect+` WHERE s.status<>'closed'
|
||||||
|
AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ?
|
||||||
|
ORDER BY s.id`, to, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueOn 某天到期的订阅。
|
||||||
|
func (db *DB) DueOn(date string) ([]Subscription, error) {
|
||||||
|
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date=? ORDER BY s.id`, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubInput 新建订阅入参。
|
||||||
|
type CreateSubInput struct {
|
||||||
|
CustomerID int64
|
||||||
|
PackageID int64
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
Days int
|
||||||
|
TotalCount int
|
||||||
|
Note string
|
||||||
|
OperatorID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSub 新建订阅,并写入初始追加记录。
|
||||||
|
func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
||||||
|
pkg, err := db.Package(in.PackageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("套餐不存在")
|
||||||
|
}
|
||||||
|
if pkg.Archived {
|
||||||
|
return nil, errors.New("该套餐已归档,不能新建订阅")
|
||||||
|
}
|
||||||
|
start, err := domain.ParseDate(in.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("开始日期格式不正确")
|
||||||
|
}
|
||||||
|
unit := pkg.Unit
|
||||||
|
endDate := ""
|
||||||
|
days := 0
|
||||||
|
total := 0
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := nowStamp()
|
||||||
|
var id int64
|
||||||
|
err = db.withTx(func(tx *sql.Tx) error {
|
||||||
|
var existing int64
|
||||||
|
err := tx.QueryRow(`SELECT id FROM subscriptions WHERE customer_id=? AND package_id=?`,
|
||||||
|
in.CustomerID, in.PackageID).Scan(&existing)
|
||||||
|
if err == nil {
|
||||||
|
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?)`,
|
||||||
|
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now)
|
||||||
|
if err != nil {
|
||||||
|
if isUnique(err) {
|
||||||
|
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, 0, 0, total, "新建订阅", nullIfZero(in.OperatorID), now)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db.Sub(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendDays 按日追加:手动指定新的结束日,自动换算计算起点与追加天数。
|
||||||
|
func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64) (domain.ExtendPlan, error) {
|
||||||
|
var plan domain.ExtendPlan
|
||||||
|
today := domain.Today()
|
||||||
|
newEnd, err := domain.ParseDate(newEndDate)
|
||||||
|
if err != nil {
|
||||||
|
return plan, errors.New("请选择新的终止日期")
|
||||||
|
}
|
||||||
|
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.UnitDay {
|
||||||
|
return errors.New("该订阅按次计费,请使用「追加次数」")
|
||||||
|
}
|
||||||
|
if s.Status == domain.StatusClosed {
|
||||||
|
return errors.New("订阅已结束,请先「重新启用」")
|
||||||
|
}
|
||||||
|
oldEnd := domain.ParseDateOr(s.EndDate, today)
|
||||||
|
plan = domain.PlanExtend(oldEnd, newEnd, today)
|
||||||
|
if plan.Backwards {
|
||||||
|
return fmt.Errorf("所选日期早于计算起点 %s,不会增加时长", domain.Date(plan.Base))
|
||||||
|
}
|
||||||
|
status := s.Status
|
||||||
|
if status == domain.StatusPaused {
|
||||||
|
status = domain.StatusActive
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=?, status=?, updated_at=? WHERE id=?`,
|
||||||
|
domain.Date(newEnd), status, nowStamp(), subID); err != nil {
|
||||||
|
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)
|
||||||
|
VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`,
|
||||||
|
subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp())
|
||||||
|
return 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 暂停订阅。
|
||||||
|
func (db *DB) PauseSub(subID, operatorID int64) error {
|
||||||
|
return db.withTx(func(tx *sql.Tx) error {
|
||||||
|
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Status == domain.StatusPaused {
|
||||||
|
return errors.New("订阅已处于暂停状态")
|
||||||
|
}
|
||||||
|
if s.Status == domain.StatusClosed {
|
||||||
|
return errors.New("订阅已结束,无法暂停")
|
||||||
|
}
|
||||||
|
now := nowStamp()
|
||||||
|
if _, err := tx.Exec(`INSERT INTO sub_pauses(sub_id,start_at) VALUES(?,?)`, subID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`UPDATE subscriptions SET status='paused', updated_at=? WHERE id=?`, now, subID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。
|
||||||
|
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
|
||||||
|
var shift int
|
||||||
|
today := domain.Today()
|
||||||
|
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.Status != domain.StatusPaused {
|
||||||
|
return errors.New("订阅未处于暂停状态")
|
||||||
|
}
|
||||||
|
var startAt string
|
||||||
|
err = tx.QueryRow(`SELECT start_at FROM sub_pauses WHERE sub_id=? AND end_at IS NULL ORDER BY id DESC LIMIT 1`, subID).Scan(&startAt)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
ps := domain.ParseDateOr(startAt[:10], today)
|
||||||
|
shift = domain.DayDiff(ps, today)
|
||||||
|
if shift < 0 {
|
||||||
|
shift = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := nowStamp()
|
||||||
|
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Unit == domain.UnitDay && shift > 0 {
|
||||||
|
oldEnd := domain.ParseDateOr(s.EndDate, today)
|
||||||
|
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
|
||||||
|
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`UPDATE subscriptions SET status='active', updated_at=? WHERE id=?`, now, subID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return shift, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseSub 结束订阅。
|
||||||
|
func (db *DB) CloseSub(subID int64) error {
|
||||||
|
now := nowStamp()
|
||||||
|
_, err := db.sql.Exec(`UPDATE subscriptions SET status='closed', closed_at=?, updated_at=? WHERE id=? AND status<>'closed'`,
|
||||||
|
now, now, subID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReopenSub 重新启用已结束的订阅。
|
||||||
|
func (db *DB) ReopenSub(subID int64) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE subscriptions SET status='active', closed_at=NULL, updated_at=? WHERE id=?`, nowStamp(), subID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。
|
||||||
|
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,''),
|
||||||
|
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,
|
||||||
|
c.tail,c.name,p.name
|
||||||
|
FROM sub_adjustments a
|
||||||
|
LEFT JOIN users u ON u.id=a.operator_id
|
||||||
|
JOIN subscriptions s ON s.id=a.sub_id
|
||||||
|
JOIN customers c ON c.id=s.customer_id
|
||||||
|
JOIN packages p ON p.id=s.package_id
|
||||||
|
WHERE a.sub_id=? ORDER BY a.id DESC`, subID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentAdjustments 最近追加记录。
|
||||||
|
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,''),
|
||||||
|
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,
|
||||||
|
c.tail,c.name,p.name
|
||||||
|
FROM sub_adjustments a
|
||||||
|
LEFT JOIN users u ON u.id=a.operator_id
|
||||||
|
JOIN subscriptions s ON s.id=a.sub_id
|
||||||
|
JOIN customers c ON c.id=s.customer_id
|
||||||
|
JOIN packages p ON p.id=s.package_id
|
||||||
|
ORDER BY a.id DESC LIMIT ?`, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
|
||||||
|
rows, err := db.sql.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Adjustment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a Adjustment
|
||||||
|
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.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PausesForSubs 批量取暂停分段。
|
||||||
|
func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
|
||||||
|
out := map[int64][]Pause{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
rows, err := db.sql.Query(`SELECT id,sub_id,start_at,IFNULL(end_at,'') FROM sub_pauses
|
||||||
|
WHERE sub_id IN (`+placeholders(len(ids))+`) ORDER BY id`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var p Pause
|
||||||
|
if err := rows.Scan(&p.ID, &p.SubID, &p.StartAt, &p.EndAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(p.StartAt) >= 10 {
|
||||||
|
p.StartDate = p.StartAt[:10]
|
||||||
|
}
|
||||||
|
if len(p.EndAt) >= 10 {
|
||||||
|
p.EndDate = p.EndAt[:10]
|
||||||
|
}
|
||||||
|
out[p.SubID] = append(out[p.SubID], p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountActiveSubs 活跃订阅数。
|
||||||
|
func (db *DB) CountActiveSubs(today string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' AND (
|
||||||
|
(s.unit='day' AND s.end_date >= ?) OR
|
||||||
|
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
|
||||||
|
)`, today, today).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubTitle 生成「1234 老张 · 包月日常」式标题。
|
||||||
|
func (s *Subscription) SubTitle() string {
|
||||||
|
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/pw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User 管理员账户。
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
IsActive bool
|
||||||
|
CreatedAt string
|
||||||
|
LastLoginAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (*User, error) {
|
||||||
|
var u User
|
||||||
|
var active int
|
||||||
|
var last sql.NullString
|
||||||
|
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.IsActive = active == 1
|
||||||
|
u.LastLoginAt = last.String
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
|
||||||
|
|
||||||
|
// CountUsers 返回账户数量。
|
||||||
|
func (db *DB) CountUsers() (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser 新建账户。
|
||||||
|
func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
|
||||||
|
hash, err := pw.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := nowStamp()
|
||||||
|
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
|
||||||
|
VALUES(?,?,?,1,?)`, username, displayName, hash, now)
|
||||||
|
if err != nil {
|
||||||
|
if isUnique(err) {
|
||||||
|
return nil, ErrDuplicate
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate 校验用户名口令。
|
||||||
|
func (db *DB) Authenticate(username, password string) (*User, error) {
|
||||||
|
var hash string
|
||||||
|
var active int
|
||||||
|
var id int64
|
||||||
|
err := db.sql.QueryRow(`SELECT id, password_hash, is_active FROM users WHERE username=?`, username).
|
||||||
|
Scan(&id, &hash, &active)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if active != 1 || !pw.Verify(password, hash) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
_, _ = db.sql.Exec(`UPDATE users SET last_login_at=? WHERE id=?`, nowStamp(), id)
|
||||||
|
return db.UserByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserByID 按 ID 取账户。
|
||||||
|
func (db *DB) UserByID(id int64) (*User, error) {
|
||||||
|
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 修改口令。
|
||||||
|
func (db *DB) UpdatePassword(userID int64, password string) error {
|
||||||
|
hash, err := pw.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserActive 启用/停用账户。
|
||||||
|
func (db *DB) SetUserActive(userID int64, active bool) error {
|
||||||
|
v := 0
|
||||||
|
if active {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
_, err := db.sql.Exec(`UPDATE users SET is_active=? WHERE id=?`, v, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 会话 ----------
|
||||||
|
|
||||||
|
// CreateSession 新建登录会话。
|
||||||
|
func (db *DB) CreateSession(token string, userID int64, ttl time.Duration, ip string) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := db.sql.Exec(`INSERT INTO sessions(token,user_id,created_at,expires_at,ip) VALUES(?,?,?,?,?)`,
|
||||||
|
token, userID, now.Format("2006-01-02 15:04:05"), now.Add(ttl).Format("2006-01-02 15:04:05"), ip)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionUser 通过令牌取账户,过期则返回 ErrNotFound。
|
||||||
|
func (db *DB) SessionUser(token string) (*User, error) {
|
||||||
|
var userID int64
|
||||||
|
var expires string
|
||||||
|
err := db.sql.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token=?`, token).Scan(&userID, &expires)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exp, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.Local)
|
||||||
|
if err != nil || exp.Before(time.Now()) {
|
||||||
|
_ = db.DeleteSession(token)
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
u, err := db.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !u.IsActive {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession 删除会话。
|
||||||
|
func (db *DB) DeleteSession(token string) error {
|
||||||
|
_, err := db.sql.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeSessions 清理过期会话。
|
||||||
|
func (db *DB) PurgeSessions() {
|
||||||
|
_, _ = db.sql.Exec(`DELETE FROM sessions WHERE expires_at < ?`, nowStamp())
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := s.db.CountUsers()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "数据库异常", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "初始化管理员", "")
|
||||||
|
s.render(w, r, "setup", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetupPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, _ := s.db.CountUsers()
|
||||||
|
if n > 0 {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sameOrigin(r) {
|
||||||
|
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
display := strings.TrimSpace(r.PostFormValue("display_name"))
|
||||||
|
pw := r.PostFormValue("password")
|
||||||
|
pw2 := r.PostFormValue("password2")
|
||||||
|
p := s.newPage(r, "初始化管理员", "")
|
||||||
|
switch {
|
||||||
|
case len(username) < 3:
|
||||||
|
s.fail(w, r, "setup", "用户名至少 3 个字符", p)
|
||||||
|
return
|
||||||
|
case len(pw) < 6:
|
||||||
|
s.fail(w, r, "setup", "密码至少 6 位", p)
|
||||||
|
return
|
||||||
|
case pw != pw2:
|
||||||
|
s.fail(w, r, "setup", "两次输入的密码不一致", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if display == "" {
|
||||||
|
display = username
|
||||||
|
}
|
||||||
|
u, err := s.db.CreateUser(username, display, pw)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, r, "setup", "创建失败:"+err.Error(), p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.db.Log(store.OpLog{OperatorID: u.ID, OperatorName: u.DisplayName, Action: "创建管理员",
|
||||||
|
Entity: "user", EntityID: u.ID, Summary: "初始化管理员账号 " + username, IP: clientIP(r)})
|
||||||
|
s.startSession(w, r, u)
|
||||||
|
s.redirect(w, r, "/packages", "管理员已创建,先添加一个服务套餐吧")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, _ := s.db.CountUsers()
|
||||||
|
if n == 0 {
|
||||||
|
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.userFromRequest(r) != nil {
|
||||||
|
http.Redirect(w, r, "/calendar", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "登录", "")
|
||||||
|
s.render(w, r, "login", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !sameOrigin(r) {
|
||||||
|
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
pw := r.PostFormValue("password")
|
||||||
|
next := r.PostFormValue("next")
|
||||||
|
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
|
||||||
|
next = "/calendar"
|
||||||
|
}
|
||||||
|
u, err := s.db.Authenticate(username, pw)
|
||||||
|
if err != nil {
|
||||||
|
p := s.newPage(r, "登录", "")
|
||||||
|
s.fail(w, r, "login", "用户名或密码不正确", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.db.Log(store.OpLog{OperatorID: u.ID, OperatorName: u.DisplayName, Action: "登录",
|
||||||
|
Entity: "session", Summary: "登录成功", IP: clientIP(r)})
|
||||||
|
s.startSession(w, r, u)
|
||||||
|
s.redirect(w, r, next, "欢迎回来,"+u.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie(cookieSession); err == nil && c.Value != "" {
|
||||||
|
_ = s.db.DeleteSession(c.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: cookieSession, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logOp 是写操作日志的便捷方法。
|
||||||
|
func (s *Server) logOp(r *http.Request, action, entity string, entityID int64, summary string) {
|
||||||
|
u := s.currentUser(r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.db.Log(store.OpLog{
|
||||||
|
OperatorID: u.ID, OperatorName: u.DisplayName, Action: action,
|
||||||
|
Entity: entity, EntityID: entityID, Summary: summary, IP: clientIP(r),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DayCell 是日历格。
|
||||||
|
type DayCell struct {
|
||||||
|
Date string
|
||||||
|
Day int
|
||||||
|
InMonth bool
|
||||||
|
IsToday bool
|
||||||
|
IsWeekend bool
|
||||||
|
IsSelected bool
|
||||||
|
IsPast bool
|
||||||
|
DueCount int
|
||||||
|
TaskCount int
|
||||||
|
DoneCount int
|
||||||
|
Heat int
|
||||||
|
Tickets []Ticket
|
||||||
|
More int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket 是日历格里的色签。
|
||||||
|
type Ticket struct {
|
||||||
|
SubID int64
|
||||||
|
Tail string
|
||||||
|
Name string
|
||||||
|
Pkg string
|
||||||
|
Color string
|
||||||
|
Done bool
|
||||||
|
Skipped bool
|
||||||
|
IsDue bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskRow 是当日待打清单的一行。
|
||||||
|
type TaskRow struct {
|
||||||
|
SubView
|
||||||
|
CheckinID int64
|
||||||
|
Done bool
|
||||||
|
Skipped bool
|
||||||
|
Note string
|
||||||
|
Operator string
|
||||||
|
}
|
||||||
|
|
||||||
|
type calendarData struct {
|
||||||
|
Year, Month int
|
||||||
|
Title string
|
||||||
|
PrevY, PrevM int
|
||||||
|
NextY, NextM int
|
||||||
|
TodayStr string
|
||||||
|
Weekdays []string
|
||||||
|
Weeks [][]DayCell
|
||||||
|
Selected string
|
||||||
|
SelectedLabel string
|
||||||
|
SelectedIsToday bool
|
||||||
|
SelectedIsPast bool
|
||||||
|
Tasks []TaskRow
|
||||||
|
Due []SubView
|
||||||
|
OtherDue []SubView
|
||||||
|
Extend *ExtendPanel
|
||||||
|
MonthDue int
|
||||||
|
MonthTasks int
|
||||||
|
MonthDone int
|
||||||
|
NoPackages bool
|
||||||
|
NoCustomers bool
|
||||||
|
Back string
|
||||||
|
}
|
||||||
|
|
||||||
|
// calSub 是日历计算用的订阅上下文。
|
||||||
|
type calSub struct {
|
||||||
|
sub store.Subscription
|
||||||
|
start time.Time
|
||||||
|
end time.Time
|
||||||
|
hasEnd bool
|
||||||
|
eval domain.Eval
|
||||||
|
pauses []store.Pause
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c calSub) pausedOn(date string) bool {
|
||||||
|
for _, p := range c.pauses {
|
||||||
|
if p.StartDate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if date >= p.StartDate && (p.EndDate == "" || date < p.EndDate) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c calSub) covers(date string) bool {
|
||||||
|
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if date < c.sub.StartDate || date > c.sub.EndDate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.sub.Unit == domain.UnitCount && c.eval.RemainingCount <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !c.pausedOn(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
today := domain.Today()
|
||||||
|
y := s.queryInt(r, "y", today.Year())
|
||||||
|
m := s.queryInt(r, "m", int(today.Month()))
|
||||||
|
if m < 1 || m > 12 {
|
||||||
|
y, m = today.Year(), int(today.Month())
|
||||||
|
}
|
||||||
|
monthStart := domain.MonthStart(y, time.Month(m))
|
||||||
|
monthEnd := domain.MonthEnd(y, time.Month(m))
|
||||||
|
|
||||||
|
sel := r.URL.Query().Get("d")
|
||||||
|
selT, err := domain.ParseDate(sel)
|
||||||
|
if sel != "" && err != nil {
|
||||||
|
sel = ""
|
||||||
|
}
|
||||||
|
if sel == "" {
|
||||||
|
if today.After(monthEnd) || today.Before(monthStart) {
|
||||||
|
sel = domain.Date(monthStart)
|
||||||
|
} else {
|
||||||
|
sel = domain.Date(today)
|
||||||
|
}
|
||||||
|
selT = domain.ParseDateOr(sel, today)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网格:以周一为首日,铺满整月所需的周数
|
||||||
|
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
|
||||||
|
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
|
||||||
|
gridEnd := domain.AddDays(gridStart, weeks*7-1)
|
||||||
|
|
||||||
|
subs, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
ids = append(ids, sub.ID)
|
||||||
|
}
|
||||||
|
pauses, err := s.db.PausesForSubs(ids)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取暂停记录失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取打卡记录失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
warn := s.warnDays()
|
||||||
|
list := make([]calSub, 0, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
cs := calSub{
|
||||||
|
sub: sub,
|
||||||
|
start: domain.ParseDateOr(sub.StartDate, today),
|
||||||
|
eval: sub.Eval(today, warn),
|
||||||
|
pauses: pauses[sub.ID],
|
||||||
|
}
|
||||||
|
if sub.EndDate != "" {
|
||||||
|
cs.end = domain.ParseDateOr(sub.EndDate, today)
|
||||||
|
cs.hasEnd = true
|
||||||
|
}
|
||||||
|
list = append(list, cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &calendarData{
|
||||||
|
Year: y, Month: m,
|
||||||
|
Title: fmt.Sprintf("%d 年 %d 月", y, m),
|
||||||
|
TodayStr: domain.Date(today),
|
||||||
|
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||||||
|
Selected: sel,
|
||||||
|
SelectedIsToday: sel == domain.Date(today),
|
||||||
|
Back: r.URL.RequestURI(),
|
||||||
|
}
|
||||||
|
data.PrevY, data.PrevM = m-1, y
|
||||||
|
if data.PrevM < 1 {
|
||||||
|
data.PrevM = 12
|
||||||
|
data.PrevY = y - 1
|
||||||
|
}
|
||||||
|
data.NextY, data.NextM = y, m+1
|
||||||
|
if data.NextM > 12 {
|
||||||
|
data.NextM = 1
|
||||||
|
data.NextY = y + 1
|
||||||
|
}
|
||||||
|
label := selT.Format("1 月 2 日")
|
||||||
|
data.SelectedLabel = label + " 周" + dowCN(sel)
|
||||||
|
data.SelectedIsPast = selT.Before(today)
|
||||||
|
|
||||||
|
// 铺格子
|
||||||
|
for wi := 0; wi < weeks; wi++ {
|
||||||
|
week := make([]DayCell, 0, 7)
|
||||||
|
for di := 0; di < 7; di++ {
|
||||||
|
day := domain.AddDays(gridStart, wi*7+di)
|
||||||
|
ds := domain.Date(day)
|
||||||
|
cell := DayCell{
|
||||||
|
Date: ds,
|
||||||
|
Day: day.Day(),
|
||||||
|
InMonth: day.Month() == time.Month(m),
|
||||||
|
IsToday: ds == domain.Date(today),
|
||||||
|
IsWeekend: di >= 5,
|
||||||
|
IsSelected: ds == sel,
|
||||||
|
IsPast: day.Before(today),
|
||||||
|
}
|
||||||
|
tickets := []Ticket{}
|
||||||
|
for _, cs := range list {
|
||||||
|
if cs.sub.EndDate == ds {
|
||||||
|
cell.DueCount++
|
||||||
|
}
|
||||||
|
if !cell.InMonth || !cs.covers(ds) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cell.TaskCount++
|
||||||
|
ci := checkins[cs.sub.ID][ds]
|
||||||
|
t := Ticket{
|
||||||
|
SubID: cs.sub.ID,
|
||||||
|
Tail: cs.sub.CustomerTail,
|
||||||
|
Name: cs.sub.CustomerName,
|
||||||
|
Pkg: shortName(cs.sub.PackageName),
|
||||||
|
Color: cs.sub.PackageColor,
|
||||||
|
Done: ci.Status == "done",
|
||||||
|
Skipped: ci.Status == "skipped",
|
||||||
|
IsDue: cs.sub.EndDate == ds,
|
||||||
|
}
|
||||||
|
if t.Done || t.Skipped {
|
||||||
|
cell.DoneCount++
|
||||||
|
}
|
||||||
|
tickets = append(tickets, t)
|
||||||
|
}
|
||||||
|
sort.SliceStable(tickets, func(i, j int) bool {
|
||||||
|
if tickets[i].Done != tickets[j].Done {
|
||||||
|
return !tickets[i].Done
|
||||||
|
}
|
||||||
|
return tickets[i].SubID < tickets[j].SubID
|
||||||
|
})
|
||||||
|
if len(tickets) > 3 {
|
||||||
|
cell.More = len(tickets) - 3
|
||||||
|
tickets = tickets[:3]
|
||||||
|
}
|
||||||
|
cell.Tickets = tickets
|
||||||
|
cell.Heat = heatLevel(cell.TaskCount + cell.DueCount)
|
||||||
|
if cell.InMonth {
|
||||||
|
data.MonthDue += cell.DueCount
|
||||||
|
data.MonthTasks += cell.TaskCount
|
||||||
|
data.MonthDone += cell.DoneCount
|
||||||
|
}
|
||||||
|
week = append(week, cell)
|
||||||
|
}
|
||||||
|
data.Weeks = append(data.Weeks, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当日清单
|
||||||
|
due := []SubView{}
|
||||||
|
tasks := []TaskRow{}
|
||||||
|
for _, cs := range list {
|
||||||
|
if cs.sub.EndDate == sel {
|
||||||
|
due = append(due, newSubView(cs.sub, today, warn))
|
||||||
|
}
|
||||||
|
if !cs.covers(sel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := newSubView(cs.sub, today, warn)
|
||||||
|
row := TaskRow{SubView: v}
|
||||||
|
if ci, ok := checkins[cs.sub.ID][sel]; ok {
|
||||||
|
row.CheckinID = ci.ID
|
||||||
|
row.Done = ci.Status == "done"
|
||||||
|
row.Skipped = ci.Status == "skipped"
|
||||||
|
row.Note = ci.Note
|
||||||
|
row.Operator = ci.OperatorName
|
||||||
|
}
|
||||||
|
tasks = append(tasks, row)
|
||||||
|
}
|
||||||
|
sort.SliceStable(tasks, func(i, j int) bool {
|
||||||
|
ai := tasks[i].Done || tasks[i].Skipped
|
||||||
|
aj := tasks[j].Done || tasks[j].Skipped
|
||||||
|
if ai != aj {
|
||||||
|
return !ai
|
||||||
|
}
|
||||||
|
return tasks[i].EndDate < tasks[j].EndDate
|
||||||
|
})
|
||||||
|
data.Tasks = tasks
|
||||||
|
data.Due = due
|
||||||
|
|
||||||
|
// 其它日期到期(不在当天的 7 天内)
|
||||||
|
if upcoming, err := s.db.RecentDue(domain.Date(today), 7); err == nil {
|
||||||
|
for _, u := range upcoming {
|
||||||
|
if u.EndDate == sel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加面板
|
||||||
|
if eid := s.queryInt(r, "extend", 0); eid > 0 {
|
||||||
|
if sub, err := s.db.Sub(int64(eid)); err == nil {
|
||||||
|
if pkg, err := s.db.Package(sub.PackageID); err == nil {
|
||||||
|
back := r.URL.Path + "?" + r.URL.RawQuery
|
||||||
|
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空态提示
|
||||||
|
if n, _ := s.db.CustomerCount(); n == 0 {
|
||||||
|
data.NoCustomers = true
|
||||||
|
}
|
||||||
|
if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 {
|
||||||
|
data.NoPackages = true
|
||||||
|
}
|
||||||
|
|
||||||
|
p := s.newPage(r, "日历", "calendar")
|
||||||
|
p.D = data
|
||||||
|
s.render(w, r, "calendar", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func heatLevel(n int) int {
|
||||||
|
switch {
|
||||||
|
case n <= 0:
|
||||||
|
return 0
|
||||||
|
case n <= 2:
|
||||||
|
return 1
|
||||||
|
case n <= 5:
|
||||||
|
return 2
|
||||||
|
case n <= 9:
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortName 取套餐名前两个字符,用作日历色签上的短标签。
|
||||||
|
func shortName(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= 2 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:2])
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type customersData struct {
|
||||||
|
List []store.Customer
|
||||||
|
Pager Pager
|
||||||
|
Q string
|
||||||
|
Total int
|
||||||
|
PackageCount int
|
||||||
|
NoPackages bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
today := domain.Today()
|
||||||
|
warn := s.warnDays()
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
pageNo := s.queryInt(r, "page", 1)
|
||||||
|
if pageNo < 1 {
|
||||||
|
pageNo = 1
|
||||||
|
}
|
||||||
|
list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取客户失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pager := newPager(r, pageNo, pageSize, total)
|
||||||
|
if pager.Page != pageNo {
|
||||||
|
list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取客户失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pkgs, _ := s.db.ListPackages(false)
|
||||||
|
data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0}
|
||||||
|
p := s.newPage(r, "客户", "customers")
|
||||||
|
p.D = data
|
||||||
|
s.render(w, r, "customers", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
type customerFormData struct {
|
||||||
|
Customer *store.Customer
|
||||||
|
IsNew bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := s.newPage(r, "新建客户", "customers")
|
||||||
|
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
|
||||||
|
s.render(w, r, "customer_form", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCustomerForm(r *http.Request) *store.Customer {
|
||||||
|
return &store.Customer{
|
||||||
|
Tail: strings.TrimSpace(r.PostFormValue("tail")),
|
||||||
|
Name: strings.TrimSpace(r.PostFormValue("name")),
|
||||||
|
GameName: strings.TrimSpace(r.PostFormValue("game_name")),
|
||||||
|
GameServer: strings.TrimSpace(r.PostFormValue("game_server")),
|
||||||
|
Contact: strings.TrimSpace(r.PostFormValue("contact")),
|
||||||
|
Tags: normalizeTags(r.PostFormValue("tags")),
|
||||||
|
Source: strings.TrimSpace(r.PostFormValue("source")),
|
||||||
|
Note: strings.TrimSpace(r.PostFormValue("note")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTags(v string) string {
|
||||||
|
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ',' || r == ' ' || r == ';' || r == ';' })
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" || seen[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCustomer(c *store.Customer) string {
|
||||||
|
if c.Tail == "" {
|
||||||
|
return "手机尾号不能为空"
|
||||||
|
}
|
||||||
|
if len([]rune(c.Tail)) > 8 {
|
||||||
|
return "手机尾号最长 8 位"
|
||||||
|
}
|
||||||
|
if c.Name == "" {
|
||||||
|
return "客户名不能为空"
|
||||||
|
}
|
||||||
|
if len([]rune(c.Name)) > 32 {
|
||||||
|
return "客户名最长 32 字"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c := readCustomerForm(r)
|
||||||
|
p := s.newPage(r, "新建客户", "customers")
|
||||||
|
if msg := validateCustomer(c); msg != "" {
|
||||||
|
p.D = &customerFormData{Customer: c, IsNew: true}
|
||||||
|
s.fail(w, r, "customer_form", msg, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := s.db.CreateCustomer(c)
|
||||||
|
if err != nil {
|
||||||
|
msg := "创建失败:" + err.Error()
|
||||||
|
if errors.Is(err, store.ErrDuplicate) {
|
||||||
|
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
|
||||||
|
}
|
||||||
|
p.D = &customerFormData{Customer: c, IsNew: true}
|
||||||
|
s.fail(w, r, "customer_form", msg, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "新建客户", "customer", id, "新建客户 "+c.Tail+" "+c.Name)
|
||||||
|
s.redirect(w, r, "/customers/"+itoa(id), "客户已创建,接着为他添加订阅")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCustomerEditPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := s.db.Customer(pathID(r))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "编辑客户", "customers")
|
||||||
|
p.D = &customerFormData{Customer: c, IsNew: false}
|
||||||
|
s.render(w, r, "customer_form", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
old, err := s.db.Customer(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := readCustomerForm(r)
|
||||||
|
c.ID = id
|
||||||
|
p := s.newPage(r, "编辑客户", "customers")
|
||||||
|
if msg := validateCustomer(c); msg != "" {
|
||||||
|
p.D = &customerFormData{Customer: c, IsNew: false}
|
||||||
|
s.fail(w, r, "customer_form", msg, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.UpdateCustomer(c); err != nil {
|
||||||
|
msg := "保存失败:" + err.Error()
|
||||||
|
if errors.Is(err, store.ErrDuplicate) {
|
||||||
|
msg = "该「尾号 + 姓名」的客户已存在"
|
||||||
|
}
|
||||||
|
p.D = &customerFormData{Customer: c, IsNew: false}
|
||||||
|
s.fail(w, r, "customer_form", msg, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "编辑客户", "customer", id, "修改客户 "+c.Tail+" "+c.Name+"(原 "+old.Tail+" "+old.Name+")")
|
||||||
|
s.redirect(w, r, "/customers/"+itoa(id), "客户资料已保存")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckinRow 是打卡历史的展示行。
|
||||||
|
type CheckinRow struct {
|
||||||
|
store.Checkin
|
||||||
|
SubName string
|
||||||
|
SubColor string
|
||||||
|
}
|
||||||
|
|
||||||
|
type customerDetailData struct {
|
||||||
|
Customer *store.Customer
|
||||||
|
Subs []SubView
|
||||||
|
Adjustments []store.Adjustment
|
||||||
|
AdjSubName map[int64]string
|
||||||
|
AdjSubColor map[int64]string
|
||||||
|
Checkins []CheckinRow
|
||||||
|
Packages []store.Package
|
||||||
|
Timeline *Timeline
|
||||||
|
Extend *ExtendPanel
|
||||||
|
TotalRemain string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
c, err := s.db.Customer(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
today := domain.Today()
|
||||||
|
warn := s.warnDays()
|
||||||
|
|
||||||
|
subs, err := s.db.SubsByCustomer(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkgs, _ := s.db.ListPackages(false)
|
||||||
|
ids := make([]int64, 0, len(subs))
|
||||||
|
subByID := map[int64]store.Subscription{}
|
||||||
|
for _, sub := range subs {
|
||||||
|
ids = append(ids, sub.ID)
|
||||||
|
subByID[sub.ID] = sub
|
||||||
|
}
|
||||||
|
pauses, _ := s.db.PausesForSubs(ids)
|
||||||
|
|
||||||
|
data := &customerDetailData{
|
||||||
|
Customer: c,
|
||||||
|
Subs: subViews(subs, today, warn),
|
||||||
|
Packages: pkgs,
|
||||||
|
Timeline: buildTimeline(subs, pauses, today),
|
||||||
|
AdjSubName: map[int64]string{},
|
||||||
|
AdjSubColor: map[int64]string{},
|
||||||
|
}
|
||||||
|
for _, sub := range subs {
|
||||||
|
data.AdjSubName[sub.ID] = sub.PackageName
|
||||||
|
data.AdjSubColor[sub.ID] = sub.PackageColor
|
||||||
|
adj, err := s.db.AdjustmentsBySub(sub.ID)
|
||||||
|
if err == nil {
|
||||||
|
data.Adjustments = append(data.Adjustments, adj...)
|
||||||
|
}
|
||||||
|
if ci, err := s.db.CheckinsForSub(sub.ID, 30); err == nil {
|
||||||
|
for _, one := range ci {
|
||||||
|
data.Checkins = append(data.Checkins, CheckinRow{Checkin: one, SubName: sub.PackageName, SubColor: sub.PackageColor})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按时间排序(新→旧)
|
||||||
|
sortAdjustments(data.Adjustments)
|
||||||
|
sortCheckins(data.Checkins)
|
||||||
|
|
||||||
|
if eid := s.queryInt(r, "extend", 0); eid > 0 {
|
||||||
|
if sub, ok := subByID[int64(eid)]; ok {
|
||||||
|
if pkg, err := s.db.Package(sub.PackageID); err == nil {
|
||||||
|
back := r.URL.Path + "?" + r.URL.RawQuery
|
||||||
|
data.Extend = buildExtendPanel(&sub, pkg, today, back, warn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := s.newPage(r, c.Tail+" "+c.Name, "customers")
|
||||||
|
p.D = data
|
||||||
|
s.render(w, r, "customer_detail", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
customerID := pathID(r)
|
||||||
|
c, err := s.db.Customer(customerID)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in := store.CreateSubInput{
|
||||||
|
CustomerID: customerID,
|
||||||
|
PackageID: int64(s.formInt(r, "package_id", 0)),
|
||||||
|
StartDate: r.PostFormValue("start_date"),
|
||||||
|
EndDate: r.PostFormValue("end_date"),
|
||||||
|
Days: s.formInt(r, "days", 0),
|
||||||
|
TotalCount: s.formInt(r, "total_count", 0),
|
||||||
|
Note: strings.TrimSpace(r.PostFormValue("note")),
|
||||||
|
}
|
||||||
|
u := s.currentUser(r)
|
||||||
|
if u != nil {
|
||||||
|
in.OperatorID = u.ID
|
||||||
|
}
|
||||||
|
sub, err := s.db.CreateSub(in)
|
||||||
|
if err != nil {
|
||||||
|
s.redirect(w, r, "/customers/"+itoa(customerID), "创建订阅失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "新建订阅", "subscription", sub.ID,
|
||||||
|
"为 "+c.Tail+" "+c.Name+" 开通 "+sub.PackageName+"("+unitLabel(sub.Unit)+")")
|
||||||
|
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 sortAdjustments(list []store.Adjustment) {
|
||||||
|
sort.SliceStable(list, func(i, j int) bool {
|
||||||
|
if list[i].CreatedAt != list[j].CreatedAt {
|
||||||
|
return list[i].CreatedAt > list[j].CreatedAt
|
||||||
|
}
|
||||||
|
return list[i].ID > list[j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortCheckins(list []CheckinRow) {
|
||||||
|
sort.SliceStable(list, func(i, j int) bool {
|
||||||
|
if list[i].ServiceDate != list[j].ServiceDate {
|
||||||
|
return list[i].ServiceDate > list[j].ServiceDate
|
||||||
|
}
|
||||||
|
return list[i].ID > list[j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type logsData struct {
|
||||||
|
List []store.OpLog
|
||||||
|
Pager Pager
|
||||||
|
Q string
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
pageNo := s.queryInt(r, "page", 1)
|
||||||
|
if pageNo < 1 {
|
||||||
|
pageNo = 1
|
||||||
|
}
|
||||||
|
list, total, err := s.db.ListLogs(q, pageSize, (pageNo-1)*pageSize)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取日志失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pager := newPager(r, pageNo, pageSize, total)
|
||||||
|
if pager.Page != pageNo {
|
||||||
|
list, total, err = s.db.ListLogs(q, pageSize, (pager.Page-1)*pageSize)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取日志失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "操作日志", "logs")
|
||||||
|
p.D = &logsData{List: list, Pager: pager, Q: q, Total: total}
|
||||||
|
s.render(w, r, "logs", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsData struct {
|
||||||
|
WarnDays int
|
||||||
|
ScreenRefresh int
|
||||||
|
Users []store.User
|
||||||
|
Backups []string
|
||||||
|
DBPath string
|
||||||
|
BackupDir string
|
||||||
|
BackupKeep int
|
||||||
|
BackupEnabled bool
|
||||||
|
ConfigPath string
|
||||||
|
ScreenPublic bool
|
||||||
|
Addrs []string
|
||||||
|
Port string
|
||||||
|
Uptime string
|
||||||
|
LogCount int
|
||||||
|
Version string
|
||||||
|
CustomerCount int
|
||||||
|
PackageCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanDuration(d time.Duration) string {
|
||||||
|
secs := int(d.Seconds())
|
||||||
|
days := secs / 86400
|
||||||
|
hours := (secs % 86400) / 3600
|
||||||
|
mins := (secs % 3600) / 60
|
||||||
|
switch {
|
||||||
|
case days > 0:
|
||||||
|
return fmt.Sprintf("%d 天 %d 小时", days, hours)
|
||||||
|
case hours > 0:
|
||||||
|
return fmt.Sprintf("%d 小时 %d 分钟", hours, mins)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d 分钟", mins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LocalAddrs() []string {
|
||||||
|
out := []string{}
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := a.(type) {
|
||||||
|
case *net.IPNet:
|
||||||
|
ip = v.IP
|
||||||
|
case *net.IPAddr:
|
||||||
|
ip = v.IP
|
||||||
|
}
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip4 := ip.To4()
|
||||||
|
if ip4 == nil || ip4.IsLoopback() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ipStr := ip4.String()
|
||||||
|
if strings.HasPrefix(ipStr, "169.254.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ipStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, _ := s.db.ListUsers()
|
||||||
|
backups, _ := s.db.ListBackups()
|
||||||
|
custN, _ := s.db.CustomerCount()
|
||||||
|
pkgs, _ := s.db.ListPackages(true)
|
||||||
|
logN, _ := s.db.LogCount()
|
||||||
|
port := s.cfg.Addr
|
||||||
|
if strings.HasPrefix(port, ":") {
|
||||||
|
port = port[1:]
|
||||||
|
}
|
||||||
|
data := &settingsData{
|
||||||
|
WarnDays: s.warnDays(),
|
||||||
|
ScreenRefresh: s.screenRefresh(),
|
||||||
|
Users: users,
|
||||||
|
Backups: backups,
|
||||||
|
DBPath: s.db.Path,
|
||||||
|
BackupDir: s.cfg.BackupDir,
|
||||||
|
BackupKeep: s.cfg.BackupKeep,
|
||||||
|
BackupEnabled: s.cfg.BackupEnabled,
|
||||||
|
ConfigPath: s.cfg.ConfigPath,
|
||||||
|
ScreenPublic: s.cfg.ScreenPublic,
|
||||||
|
Addrs: LocalAddrs(),
|
||||||
|
Port: port,
|
||||||
|
Uptime: humanDuration(time.Since(s.started)),
|
||||||
|
LogCount: logN,
|
||||||
|
Version: s.cfg.Version,
|
||||||
|
CustomerCount: custN,
|
||||||
|
PackageCount: len(pkgs),
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "设置", "settings")
|
||||||
|
p.D = data
|
||||||
|
s.render(w, r, "settings", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
warn := s.formInt(r, "warn_days", 3)
|
||||||
|
if warn < 0 {
|
||||||
|
warn = 0
|
||||||
|
}
|
||||||
|
if 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("screen_refresh", strconv.Itoa(refresh))
|
||||||
|
s.logOp(r, "修改设置", "settings", 0,
|
||||||
|
fmt.Sprintf("预警 %d 天、大屏刷新 %d 秒", warn, refresh))
|
||||||
|
s.redirect(w, r, "/settings", "设置已保存")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.currentUser(r)
|
||||||
|
old := r.PostFormValue("old_password")
|
||||||
|
pw := r.PostFormValue("password")
|
||||||
|
pw2 := r.PostFormValue("password2")
|
||||||
|
if u == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.db.Authenticate(u.Username, old); err != nil {
|
||||||
|
s.redirect(w, r, "/settings", "原密码不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(pw) < 6 {
|
||||||
|
s.redirect(w, r, "/settings", "新密码至少 6 位")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pw != pw2 {
|
||||||
|
s.redirect(w, r, "/settings", "两次输入的新密码不一致")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.UpdatePassword(u.ID, pw); err != nil {
|
||||||
|
s.redirect(w, r, "/settings", "修改失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "修改密码", "user", u.ID, "管理员 "+u.Username+" 修改了自己的密码")
|
||||||
|
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) {
|
||||||
|
path, err := s.db.BackupNow(s.cfg.BackupKeep)
|
||||||
|
if err != nil {
|
||||||
|
s.redirect(w, r, "/settings", "备份失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "备份数据", "backup", 0, "手动备份 "+path)
|
||||||
|
s.redirect(w, r, "/settings", "已备份:"+path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kind := strings.TrimSuffix(r.PathValue("kind"), ".csv")
|
||||||
|
var set *store.ExportRowSet
|
||||||
|
var err error
|
||||||
|
switch kind {
|
||||||
|
case "customers":
|
||||||
|
set, err = s.db.ExportCustomers()
|
||||||
|
case "subscriptions":
|
||||||
|
set, err = s.db.ExportSubscriptions(domain.Today(), s.warnDays())
|
||||||
|
case "logs":
|
||||||
|
set, err = s.db.ExportLogs()
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "导出失败:"+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition",
|
||||||
|
fmt.Sprintf("attachment; filename=\"daili-%s-%s.csv\"", kind, time.Now().Format("20060102")))
|
||||||
|
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||||
|
cw := csv.NewWriter(w)
|
||||||
|
_ = cw.Write(set.Header)
|
||||||
|
for _, row := range set.Rows {
|
||||||
|
_ = cw.Write(row)
|
||||||
|
}
|
||||||
|
cw.Flush()
|
||||||
|
s.logOp(r, "导出数据", "export", 0, "导出 "+kind+"("+strconv.Itoa(len(set.Rows))+" 行)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type packagesData struct {
|
||||||
|
List []store.Package
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := s.db.ListPackages(true)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取套餐失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "套餐", "packages")
|
||||||
|
p.D = &packagesData{List: list}
|
||||||
|
s.render(w, r, "packages", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageFormData struct {
|
||||||
|
Package *store.Package
|
||||||
|
IsNew bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := s.newPage(r, "新增套餐", "packages")
|
||||||
|
p.D = &packageFormData{
|
||||||
|
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
|
||||||
|
IsNew: true,
|
||||||
|
}
|
||||||
|
s.render(w, r, "package_form", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPackageForm(r *http.Request) *store.Package {
|
||||||
|
p := &store.Package{
|
||||||
|
Name: strings.TrimSpace(r.PostFormValue("name")),
|
||||||
|
Unit: strings.TrimSpace(r.PostFormValue("unit")),
|
||||||
|
DefaultAmount: 0,
|
||||||
|
Color: strings.TrimSpace(r.PostFormValue("color")),
|
||||||
|
Note: strings.TrimSpace(r.PostFormValue("note")),
|
||||||
|
SortOrder: 0,
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_amount"))); err == nil {
|
||||||
|
p.DefaultAmount = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("sort_order"))); err == nil {
|
||||||
|
p.SortOrder = v
|
||||||
|
}
|
||||||
|
if price := strings.TrimSpace(r.PostFormValue("price")); price != "" {
|
||||||
|
if f, err := strconv.ParseFloat(price, 64); err == nil {
|
||||||
|
p.PriceCents = int(f*100 + 0.5)
|
||||||
|
p.HasPrice = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Unit == "" {
|
||||||
|
p.Unit = domain.UnitDay
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePackage(p *store.Package) string {
|
||||||
|
if p.Name == "" {
|
||||||
|
return "套餐名称不能为空"
|
||||||
|
}
|
||||||
|
if len([]rune(p.Name)) > 32 {
|
||||||
|
return "套餐名称最长 32 字"
|
||||||
|
}
|
||||||
|
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
|
||||||
|
return "套餐类型不正确"
|
||||||
|
}
|
||||||
|
if p.DefaultAmount <= 0 {
|
||||||
|
return "默认天数/次数必须大于 0"
|
||||||
|
}
|
||||||
|
if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
|
||||||
|
return "请选择套餐颜色"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pkg := readPackageForm(r)
|
||||||
|
p := s.newPage(r, "新增套餐", "packages")
|
||||||
|
if msg := validatePackage(pkg); msg != "" {
|
||||||
|
p.D = &packageFormData{Package: pkg, IsNew: true}
|
||||||
|
s.fail(w, r, "package_form", msg, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := s.db.CreatePackage(pkg)
|
||||||
|
if err != nil {
|
||||||
|
p.D = &packageFormData{Package: pkg, IsNew: true}
|
||||||
|
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "新增套餐", "package", id, "新增套餐 "+pkg.Name)
|
||||||
|
s.redirect(w, r, "/packages", "套餐已创建,现在可以给客户开通了")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
pkg, err := s.db.Package(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "编辑套餐", "packages")
|
||||||
|
p.D = &packageFormData{Package: pkg, IsNew: false}
|
||||||
|
s.render(w, r, "package_form", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
pkg := readPackageForm(r)
|
||||||
|
pkg.ID = id
|
||||||
|
p := s.newPage(r, "编辑套餐", "packages")
|
||||||
|
if msg := validatePackage(pkg); msg != "" {
|
||||||
|
p.D = &packageFormData{Package: pkg, IsNew: false}
|
||||||
|
s.fail(w, r, "package_form", msg, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.UpdatePackage(pkg); err != nil {
|
||||||
|
p.D = &packageFormData{Package: pkg, IsNew: false}
|
||||||
|
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "编辑套餐", "package", id, "修改套餐 "+pkg.Name)
|
||||||
|
s.redirect(w, r, "/packages", "套餐已保存")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePackageArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
pkg, err := s.db.Package(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archived := !pkg.Archived
|
||||||
|
if err := s.db.SetPackageArchived(id, archived); err != nil {
|
||||||
|
s.redirect(w, r, "/packages", "操作失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := "归档套餐"
|
||||||
|
msg := "套餐已归档,不再允许新订阅(已有订阅不受影响)"
|
||||||
|
if !archived {
|
||||||
|
action = "取消归档"
|
||||||
|
msg = "套餐已恢复"
|
||||||
|
}
|
||||||
|
s.logOp(r, action, "package", id, action+" "+pkg.Name)
|
||||||
|
s.redirect(w, r, "/packages", msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type screenDay struct {
|
||||||
|
Date string
|
||||||
|
Day int
|
||||||
|
InMonth bool
|
||||||
|
IsToday bool
|
||||||
|
IsPast bool
|
||||||
|
Due int
|
||||||
|
Heat int
|
||||||
|
}
|
||||||
|
|
||||||
|
type screenData struct {
|
||||||
|
Stats *store.Stats
|
||||||
|
MonthTitle string
|
||||||
|
Year, Month int
|
||||||
|
Weeks [][]screenDay
|
||||||
|
Weekdays []string
|
||||||
|
Top []SubView
|
||||||
|
DueToday []SubView
|
||||||
|
TodayList []SubView
|
||||||
|
Recent []store.Adjustment
|
||||||
|
TodayTasks int
|
||||||
|
TodayDone int
|
||||||
|
Refresh int
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
today := domain.Today()
|
||||||
|
warn := s.warnDays()
|
||||||
|
stats, err := s.db.ScreenStats(today, warn)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取大屏数据失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := &screenData{
|
||||||
|
Stats: stats,
|
||||||
|
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
|
||||||
|
Year: today.Year(),
|
||||||
|
Month: int(today.Month()),
|
||||||
|
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||||||
|
Top: subViews(stats.Top, today, warn),
|
||||||
|
DueToday: subViews(stats.DueTodaySubs, today, warn),
|
||||||
|
Refresh: s.screenRefresh(),
|
||||||
|
Version: s.cfg.Version,
|
||||||
|
}
|
||||||
|
if len(data.DueToday) > 6 {
|
||||||
|
data.DueToday = data.DueToday[:6]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本月热力
|
||||||
|
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
|
||||||
|
for wi := 0; wi < weeks; wi++ {
|
||||||
|
week := make([]screenDay, 0, 7)
|
||||||
|
for di := 0; di < 7; di++ {
|
||||||
|
d := domain.AddDays(gridStart, wi*7+di)
|
||||||
|
ds := domain.Date(d)
|
||||||
|
due := stats.Heat[ds]
|
||||||
|
week = append(week, screenDay{
|
||||||
|
Date: ds,
|
||||||
|
Day: d.Day(),
|
||||||
|
InMonth: d.Month() == today.Month(),
|
||||||
|
IsToday: ds == domain.Date(today),
|
||||||
|
IsPast: d.Before(today),
|
||||||
|
Due: due,
|
||||||
|
Heat: heatLevel(due),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data.Weeks = append(data.Weeks, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
if adj, err := s.db.RecentAdjustments(6); err == nil {
|
||||||
|
data.Recent = adj
|
||||||
|
}
|
||||||
|
list, done := s.todayTaskList(today)
|
||||||
|
data.TodayTasks = len(list)
|
||||||
|
data.TodayDone = done
|
||||||
|
data.TodayList = subViews(list, today, warn)
|
||||||
|
|
||||||
|
p := s.newPage(r, "大屏", "screen")
|
||||||
|
p.D = data
|
||||||
|
s.render(w, r, "screen", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// todayTaskList 返回今天应服务的订阅与已完成数量。
|
||||||
|
func (s *Server) todayTaskList(today time.Time) ([]store.Subscription, int) {
|
||||||
|
ts := domain.Date(today)
|
||||||
|
subs, err := s.db.CalendarSubs(ts, ts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
ids = append(ids, sub.ID)
|
||||||
|
}
|
||||||
|
pauses, _ := s.db.PausesForSubs(ids)
|
||||||
|
checkins, _ := s.db.CheckinsBetween(ts, ts)
|
||||||
|
warn := s.warnDays()
|
||||||
|
out := []store.Subscription{}
|
||||||
|
done := 0
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.Status == domain.StatusClosed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sub.StartDate > ts || sub.EndDate == "" || sub.EndDate < ts {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cs := calSub{sub: sub, pauses: pauses[sub.ID], hasEnd: true, eval: sub.Eval(today, warn)}
|
||||||
|
if sub.Unit == domain.UnitCount && cs.eval.RemainingCount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cs.pausedOn(ts) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sub)
|
||||||
|
if ci, ok := checkins[sub.ID][ts]; ok && ci.Status == "done" {
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, done
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type subsData struct {
|
||||||
|
List []SubView
|
||||||
|
Pager Pager
|
||||||
|
State string
|
||||||
|
PackageID int64
|
||||||
|
Q string
|
||||||
|
Packages []store.Package
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
today := domain.Today()
|
||||||
|
warn := s.warnDays()
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
if state == "" {
|
||||||
|
state = "active"
|
||||||
|
}
|
||||||
|
packageID := int64(s.queryInt(r, "package_id", 0))
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
pageNo := s.queryInt(r, "page", 1)
|
||||||
|
if pageNo < 1 {
|
||||||
|
pageNo = 1
|
||||||
|
}
|
||||||
|
f := store.SubFilter{Q: q, State: state, PackageID: packageID, Limit: pageSize, Offset: (pageNo - 1) * pageSize, Today: today}
|
||||||
|
list, total, err := s.db.ListSubs(f)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pager := newPager(r, pageNo, pageSize, total)
|
||||||
|
if pager.Page != pageNo {
|
||||||
|
f.Offset = (pager.Page - 1) * pageSize
|
||||||
|
list, total, err = s.db.ListSubs(f)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pkgs, _ := s.db.ListPackages(true)
|
||||||
|
data := &subsData{
|
||||||
|
List: subViews(list, today, warn), Pager: pager, State: state,
|
||||||
|
PackageID: packageID, Q: q, Packages: pkgs, Total: total,
|
||||||
|
}
|
||||||
|
p := s.newPage(r, "订阅", "subscriptions")
|
||||||
|
p.D = data
|
||||||
|
s.render(w, r, "subscriptions", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSubExtend(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
sub, err := s.db.Sub(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := s.currentUser(r)
|
||||||
|
var opID int64
|
||||||
|
if u != nil {
|
||||||
|
opID = u.ID
|
||||||
|
}
|
||||||
|
note := strings.TrimSpace(r.PostFormValue("note"))
|
||||||
|
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
|
||||||
|
|
||||||
|
if sub.Unit == domain.UnitDay {
|
||||||
|
newEnd := strings.TrimSpace(r.PostFormValue("new_end_date"))
|
||||||
|
plan, err := s.db.ExtendDays(id, newEnd, note, opID)
|
||||||
|
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())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "追加次数", "subscription", id, fmt.Sprintf("%s 追加 %d 次", sub.SubTitle(), add))
|
||||||
|
s.redirect(w, r, back, fmt.Sprintf("已追加 %d 次", add))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSubAction(action string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
sub, err := s.db.Sub(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := backURL(r, "/customers/"+itoa(sub.CustomerID))
|
||||||
|
switch action {
|
||||||
|
case "pause":
|
||||||
|
if err := s.db.PauseSub(id, 0); err != nil {
|
||||||
|
s.redirect(w, r, back, "暂停失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "暂停订阅", "subscription", id, sub.SubTitle()+" 暂停")
|
||||||
|
s.redirect(w, r, back, "已暂停,剩余时长将冻结")
|
||||||
|
case "resume":
|
||||||
|
shift, err := s.db.ResumeSub(id, 0)
|
||||||
|
if err != nil {
|
||||||
|
s.redirect(w, r, back, "恢复失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "已恢复"
|
||||||
|
if shift > 0 {
|
||||||
|
msg = fmt.Sprintf("已恢复,结束日顺延 %d 天", shift)
|
||||||
|
}
|
||||||
|
s.logOp(r, "恢复订阅", "subscription", id, fmt.Sprintf("%s 恢复,顺延 %d 天", sub.SubTitle(), shift))
|
||||||
|
s.redirect(w, r, back, msg)
|
||||||
|
case "close":
|
||||||
|
if err := s.db.CloseSub(id); err != nil {
|
||||||
|
s.redirect(w, r, back, "结束失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "结束订阅", "subscription", id, sub.SubTitle()+" 结束")
|
||||||
|
s.redirect(w, r, back, "订阅已结束")
|
||||||
|
case "reopen":
|
||||||
|
if err := s.db.ReopenSub(id); err != nil {
|
||||||
|
s.redirect(w, r, back, "启用失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "重新启用", "subscription", id, sub.SubTitle()+" 重新启用")
|
||||||
|
s.redirect(w, r, back, "订阅已重新启用")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
sub, err := s.db.Sub(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := backURL(r, "/calendar?d="+r.PostFormValue("date"))
|
||||||
|
date := strings.TrimSpace(r.PostFormValue("date"))
|
||||||
|
status := strings.TrimSpace(r.PostFormValue("status"))
|
||||||
|
note := strings.TrimSpace(r.PostFormValue("note"))
|
||||||
|
u := s.currentUser(r)
|
||||||
|
var opID int64
|
||||||
|
if u != nil {
|
||||||
|
opID = u.ID
|
||||||
|
}
|
||||||
|
if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil {
|
||||||
|
s.redirect(w, r, back, "打卡失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := "已完成"
|
||||||
|
if status == "skipped" {
|
||||||
|
label = "已跳过"
|
||||||
|
}
|
||||||
|
s.logOp(r, "打卡", "subscription", id, fmt.Sprintf("%s %s 打卡:%s", sub.SubTitle(), date, label))
|
||||||
|
s.redirect(w, r, back, "已记录:"+sub.CustomerTail+" "+sub.CustomerName+" "+date)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCheckinDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := pathID(r)
|
||||||
|
back := backURL(r, "/calendar")
|
||||||
|
subID, err := s.db.DeleteCheckin(id, 0)
|
||||||
|
if err != nil {
|
||||||
|
s.redirect(w, r, back, "撤销失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id))
|
||||||
|
s.redirect(w, r, back, "已撤销打卡")
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
// Package web 提供 HTTP 路由、会话、模板渲染与页面处理器。
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/_layout.html templates/_screen_layout.html templates/_partials.html templates/pages/*.html
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
cookieSession = "daili_sid"
|
||||||
|
cookieFlash = "daili_flash"
|
||||||
|
sessionTTL = 7 * 24 * time.Hour
|
||||||
|
pageSize = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 是服务配置。
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DBPath string
|
||||||
|
BackupKeep int
|
||||||
|
BackupDir string
|
||||||
|
BackupEnabled bool
|
||||||
|
ScreenPublic bool
|
||||||
|
ConfigPath string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server 是应用服务。
|
||||||
|
type Server struct {
|
||||||
|
db *store.DB
|
||||||
|
cfg Config
|
||||||
|
tpl *renderer
|
||||||
|
handler http.Handler
|
||||||
|
started time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const userCtxKey ctxKey = 1
|
||||||
|
|
||||||
|
// New 构造服务。
|
||||||
|
func New(db *store.DB, cfg Config) (*Server, error) {
|
||||||
|
r, err := newRenderer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &Server{db: db, cfg: cfg, tpl: r, started: time.Now()}
|
||||||
|
s.handler = s.routes()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler 返回 HTTP 处理器。
|
||||||
|
func (s *Server) Handler() http.Handler { return s.handler }
|
||||||
|
|
||||||
|
func (s *Server) routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
sub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
mux.Handle("GET /static/", cacheControl(http.StripPrefix("/static/", http.FileServerFS(sub))))
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /login", s.handleLoginPage)
|
||||||
|
mux.HandleFunc("POST /login", s.handleLoginPost)
|
||||||
|
mux.HandleFunc("GET /setup", s.handleSetupPage)
|
||||||
|
mux.HandleFunc("POST /setup", s.handleSetupPost)
|
||||||
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/calendar", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /calendar", s.auth(s.handleCalendar))
|
||||||
|
mux.HandleFunc("GET /customers", s.auth(s.handleCustomers))
|
||||||
|
mux.HandleFunc("GET /customers/new", s.auth(s.handleCustomerNewPage))
|
||||||
|
mux.HandleFunc("POST /customers", s.auth(s.handleCustomerCreate))
|
||||||
|
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
|
||||||
|
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
|
||||||
|
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
|
||||||
|
mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
|
||||||
|
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
|
||||||
|
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
|
||||||
|
mux.HandleFunc("POST /subscriptions/{id}/pause", s.auth(s.handleSubAction("pause")))
|
||||||
|
mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume")))
|
||||||
|
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
|
||||||
|
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
|
||||||
|
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
|
||||||
|
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
|
||||||
|
mux.HandleFunc("GET /packages", s.auth(s.handlePackages))
|
||||||
|
mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage))
|
||||||
|
mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate))
|
||||||
|
mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
|
||||||
|
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
|
||||||
|
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
|
||||||
|
if s.cfg.ScreenPublic {
|
||||||
|
// 大屏免登录:仅局域网部署时使用,内容为只读看板
|
||||||
|
mux.HandleFunc("GET /screen", s.handleScreen)
|
||||||
|
} else {
|
||||||
|
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
|
||||||
|
mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
|
||||||
|
mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave))
|
||||||
|
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
|
||||||
|
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
|
||||||
|
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
|
||||||
|
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
|
||||||
|
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
|
||||||
|
|
||||||
|
return s.noCacheHTML(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheControl(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) noCacheHTML(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/static/") {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Referrer-Policy", "same-origin")
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 会话与鉴权 ----------
|
||||||
|
|
||||||
|
func randomToken() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||||
|
token := randomToken()
|
||||||
|
if err := s.db.CreateSession(token, u.ID, sessionTTL, clientIP(r)); err != nil {
|
||||||
|
log.Printf("创建会话失败: %v", err)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieSession, Value: token, Path: "/", HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) userFromRequest(r *http.Request) *store.User {
|
||||||
|
c, err := r.Cookie(cookieSession)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := s.db.SessionUser(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) currentUser(r *http.Request) *store.User {
|
||||||
|
if u, ok := r.Context().Value(userCtxKey).(*store.User); ok {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameOrigin(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(u.Host, r.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost && !sameOrigin(r) {
|
||||||
|
http.Error(w, "跨站请求被拒绝", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := s.userFromRequest(r)
|
||||||
|
if u == nil {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
next := url.QueryEscape(r.URL.RequestURI())
|
||||||
|
http.Redirect(w, r, "/login?next="+next, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userCtxKey, u)
|
||||||
|
h(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 渲染 ----------
|
||||||
|
|
||||||
|
type renderer struct {
|
||||||
|
pages map[string]*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRenderer() (*renderer, error) {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"seq": func(n int) []int {
|
||||||
|
out := make([]int, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
"sub": func(a, b int) int { return a - b },
|
||||||
|
"money": func(cents int) string {
|
||||||
|
return fmt.Sprintf("%.0f", float64(cents)/100)
|
||||||
|
},
|
||||||
|
"pct": func(f float64) string { return fmt.Sprintf("%.2f", f) },
|
||||||
|
"md": func(s string) string { return mdOf(s) },
|
||||||
|
"dow": dowCN,
|
||||||
|
}
|
||||||
|
base, err := template.New("base").Funcs(funcs).ParseFS(templateFS,
|
||||||
|
"templates/_layout.html", "templates/_partials.html")
|
||||||
|
if err != nil {
|
||||||
|
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{}
|
||||||
|
entries, err := fs.Glob(templateFS, "templates/pages/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range entries {
|
||||||
|
name := strings.TrimSuffix(strings.TrimPrefix(p, "templates/pages/"), ".html")
|
||||||
|
var baseSet *template.Template
|
||||||
|
if name == "screen" {
|
||||||
|
baseSet = screenBase
|
||||||
|
} else {
|
||||||
|
baseSet = base
|
||||||
|
}
|
||||||
|
clone, err := baseSet.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := clone.ParseFS(templateFS, p); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析模板 %s 失败: %w", p, err)
|
||||||
|
}
|
||||||
|
pages[name] = clone
|
||||||
|
}
|
||||||
|
return &renderer{pages: pages}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mdOf(s string) string {
|
||||||
|
if len(s) >= 10 {
|
||||||
|
return s[5:10]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func dowCN(s string) string {
|
||||||
|
t, err := domain.ParseDate(s)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return [...]string{"一", "二", "三", "四", "五", "六", "日"}[domain.WeekdayIndex(t)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// page 是所有页面模板的根数据。
|
||||||
|
type page struct {
|
||||||
|
User *store.User
|
||||||
|
Title string
|
||||||
|
Nav string
|
||||||
|
Flash string
|
||||||
|
Error string
|
||||||
|
Today time.Time
|
||||||
|
TodayStr string
|
||||||
|
WarnDays int
|
||||||
|
Version string
|
||||||
|
Query string
|
||||||
|
Next string
|
||||||
|
DueToday int
|
||||||
|
Due3 int
|
||||||
|
Addr string
|
||||||
|
D any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) warnDays() int {
|
||||||
|
v, err := strconv.Atoi(s.db.Setting("warn_days", "3"))
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
u := s.currentUser(r)
|
||||||
|
today := domain.Today()
|
||||||
|
p := &page{
|
||||||
|
User: u,
|
||||||
|
Title: title,
|
||||||
|
Nav: nav,
|
||||||
|
Today: today,
|
||||||
|
TodayStr: domain.Date(today),
|
||||||
|
WarnDays: s.warnDays(),
|
||||||
|
Version: s.cfg.Version,
|
||||||
|
Query: r.URL.Query().Get("q"),
|
||||||
|
Next: r.URL.Query().Get("next"),
|
||||||
|
}
|
||||||
|
if u != nil {
|
||||||
|
if due, due3, err := s.db.Badges(today); err == nil {
|
||||||
|
p.DueToday, p.Due3 = due, due3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
c, err := r.Cookie(cookieFlash)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
msg, _ := url.QueryUnescape(c.Value)
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: cookieFlash, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFlash(w http.ResponseWriter, msg string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieFlash, Value: url.QueryEscape(msg), Path: "/",
|
||||||
|
MaxAge: 30, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, to, flash string) {
|
||||||
|
if flash != "" {
|
||||||
|
setFlash(w, flash)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, d *page) {
|
||||||
|
if d.User == nil {
|
||||||
|
d.User = s.currentUser(r)
|
||||||
|
}
|
||||||
|
if d.Flash == "" {
|
||||||
|
d.Flash = s.popFlash(w, r)
|
||||||
|
}
|
||||||
|
if d.Today.IsZero() {
|
||||||
|
d.Today = domain.Today()
|
||||||
|
d.TodayStr = domain.Date(d.Today)
|
||||||
|
}
|
||||||
|
if d.WarnDays == 0 {
|
||||||
|
d.WarnDays = s.warnDays()
|
||||||
|
}
|
||||||
|
tpl, ok := s.tpl.pages[name]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("模板不存在: %s", name)
|
||||||
|
http.Error(w, "页面不存在", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layout := "layout"
|
||||||
|
if name == "screen" {
|
||||||
|
layout = "screenlayout"
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.ExecuteTemplate(&buf, layout, d); err != nil {
|
||||||
|
log.Printf("渲染 %s 失败: %v", name, err)
|
||||||
|
http.Error(w, "页面渲染失败: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = buf.WriteTo(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) fail(w http.ResponseWriter, r *http.Request, name string, msg string, d *page) {
|
||||||
|
if d == nil {
|
||||||
|
d = s.newPage(r, "出错了", "")
|
||||||
|
}
|
||||||
|
d.Error = msg
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
s.render(w, r, name, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 参数 ----------
|
||||||
|
|
||||||
|
func (s *Server) queryInt(r *http.Request, key string, def int) int {
|
||||||
|
v, err := strconv.Atoi(r.URL.Query().Get(key))
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) formInt(r *http.Request, key string, def int) int {
|
||||||
|
v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue(key)))
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathID(r *http.Request) int64 {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// backURL 取安全的返回地址。
|
||||||
|
func backURL(r *http.Request, def string) string {
|
||||||
|
if b := strings.TrimSpace(r.PostFormValue("back")); strings.HasPrefix(b, "/") && !strings.HasPrefix(b, "//") {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if ref := r.Referer(); ref != "" {
|
||||||
|
if u, err := url.Parse(ref); err == nil && strings.EqualFold(u.Host, r.Host) {
|
||||||
|
p := u.RequestURI()
|
||||||
|
if strings.HasPrefix(p, "/") {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
/* ============================================================
|
||||||
|
代打台 · 管理端(浅色「接单台」)
|
||||||
|
纸感底色 + 墨色文字 + 松墨绿主色;色签(ticket)是唯一强装饰
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--paper: #f7f6f2;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--ink: #1a1c21;
|
||||||
|
--ink-2: #43474f;
|
||||||
|
--muted: #7a7e86;
|
||||||
|
--line: #e3e0d6;
|
||||||
|
--line-2: #efede6;
|
||||||
|
--accent: #2f5d50;
|
||||||
|
--accent-ink: #22453b;
|
||||||
|
--accent-soft: #e7efea;
|
||||||
|
--danger: #b4231f;
|
||||||
|
--danger-soft: #fbeceb;
|
||||||
|
--warn: #a85e12;
|
||||||
|
--warn-soft: #fbf1e3;
|
||||||
|
--info: #41556b;
|
||||||
|
--info-soft: #eaeef2;
|
||||||
|
--radius: 3px;
|
||||||
|
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
|
||||||
|
--sans: "Microsoft YaHei UI", "PingFang SC", "Hiragino Sans GB", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--line-2) 1px, transparent 1px) 0 0 / 100% 32px,
|
||||||
|
var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
|
||||||
|
h1 { font-size: 22px; }
|
||||||
|
h2 { font-size: 15px; }
|
||||||
|
h3 { font-size: 13px; }
|
||||||
|
|
||||||
|
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.right { text-align: right; }
|
||||||
|
|
||||||
|
/* ---------- 顶栏 ---------- */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 40;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 0 20px; height: 56px;
|
||||||
|
background: rgba(247, 246, 242, 0.94);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 8px; color: var(--ink); text-decoration: none; }
|
||||||
|
.brand:hover { text-decoration: none; }
|
||||||
|
.brand-mark {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
font-size: 14px; font-weight: 700;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 2px 2px 0 rgba(47, 93, 80, 0.18);
|
||||||
|
}
|
||||||
|
.brand-name { font-weight: 700; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
.search { position: relative; flex: 1 1 260px; max-width: 380px; }
|
||||||
|
.search input {
|
||||||
|
width: 100%; height: 34px;
|
||||||
|
padding: 0 34px 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
font: inherit; color: var(--ink);
|
||||||
|
}
|
||||||
|
.search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
.search kbd {
|
||||||
|
position: absolute; right: 8px; top: 7px;
|
||||||
|
font-family: var(--mono); font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line); border-bottom-width: 2px;
|
||||||
|
border-radius: 3px; padding: 0 5px; background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav { display: flex; gap: 2px; }
|
||||||
|
.nav a {
|
||||||
|
padding: 6px 10px; border-radius: var(--radius);
|
||||||
|
color: var(--ink-2); font-size: 13px;
|
||||||
|
}
|
||||||
|
.nav a:hover { background: var(--surface); text-decoration: none; }
|
||||||
|
.nav a.on { background: var(--accent); color: #fff; }
|
||||||
|
.nav a.nav-screen { color: var(--accent); }
|
||||||
|
|
||||||
|
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
height: 28px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--line); border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 12px; color: var(--ink-2); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill b { font-family: var(--mono); font-size: 13px; }
|
||||||
|
.pill:hover { text-decoration: none; border-color: var(--accent); }
|
||||||
|
.pill-due b { color: var(--danger); }
|
||||||
|
.pill-warn b { color: var(--warn); }
|
||||||
|
|
||||||
|
.me { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
.me a { color: var(--ink-2); }
|
||||||
|
.link-btn {
|
||||||
|
border: 0; background: none; padding: 0; cursor: pointer;
|
||||||
|
color: var(--muted); font: inherit; font-size: 12px;
|
||||||
|
}
|
||||||
|
.link-btn:hover { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ---------- 提示条 ---------- */
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
position: fixed; z-index: 90;
|
||||||
|
left: 50%; top: 68px; transform: translateX(-50%);
|
||||||
|
max-width: min(680px, calc(100vw - 32px));
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 6px 24px rgba(26, 28, 33, 0.16);
|
||||||
|
font-size: 13px;
|
||||||
|
animation: flash-in 0.22s ease-out;
|
||||||
|
}
|
||||||
|
.flash-error { background: var(--danger); }
|
||||||
|
.flash.hide { opacity: 0; transition: opacity 0.4s ease; }
|
||||||
|
@keyframes flash-in { from { opacity: 0; transform: translate(-50%, -6px); } }
|
||||||
|
|
||||||
|
/* ---------- 布局 ---------- */
|
||||||
|
|
||||||
|
main { padding: 0 20px 40px; }
|
||||||
|
main.with-top { padding-top: 22px; }
|
||||||
|
|
||||||
|
.page { max-width: 1280px; margin: 0 auto; }
|
||||||
|
.page.narrow { max-width: 860px; }
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.page-head p { margin: 4px 0 0; font-size: 12px; }
|
||||||
|
.page-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
max-width: 1280px; margin: 0 auto; padding: 18px 20px 28px;
|
||||||
|
font-size: 12px; color: var(--muted);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin: 0 0 14px; padding: 10px 14px;
|
||||||
|
background: var(--warn-soft); border-left: 3px solid var(--warn);
|
||||||
|
font-size: 13px; border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
.notice.note { background: var(--accent-soft); border-left-color: var(--accent); white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* ---------- 按钮与表单 ---------- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
height: 34px; padding: 0 14px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
background: var(--surface); color: var(--ink);
|
||||||
|
font: inherit; font-size: 13px; cursor: pointer;
|
||||||
|
white-space: nowrap; text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--ink-2); text-decoration: none; }
|
||||||
|
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); }
|
||||||
|
.btn-ghost { background: transparent; border-color: transparent; color: var(--ink-2); }
|
||||||
|
.btn-ghost:hover { background: var(--surface); border-color: var(--line); }
|
||||||
|
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
|
||||||
|
.btn-square { width: 34px; padding: 0; font-size: 18px; line-height: 1; }
|
||||||
|
.btn-block { width: 100%; }
|
||||||
|
.btn.disabled { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
|
form { margin: 0; }
|
||||||
|
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 14px; }
|
||||||
|
.field > span { font-size: 12px; color: var(--ink-2); font-weight: 600; }
|
||||||
|
.field > span i { color: var(--danger); font-style: normal; }
|
||||||
|
.field input[type="text"], .field input[type="password"], .field input[type="number"],
|
||||||
|
.field input[type="date"], .field input[type="search"], .field select, .field textarea {
|
||||||
|
width: 100%; min-height: 34px; padding: 6px 10px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
background: var(--surface); font: inherit; color: var(--ink);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; line-height: 1.6; }
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus {
|
||||||
|
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.field input[type="color"] { width: 100%; height: 34px; padding: 2px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); cursor: pointer; }
|
||||||
|
.fieldset { border: 0; padding: 0; }
|
||||||
|
|
||||||
|
.radios { display: grid; gap: 8px; }
|
||||||
|
.radios label {
|
||||||
|
display: grid; grid-template-columns: 18px 1fr; grid-template-rows: auto auto;
|
||||||
|
column-gap: 10px; align-items: center;
|
||||||
|
padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
background: var(--surface); cursor: pointer;
|
||||||
|
}
|
||||||
|
.radios label:hover { border-color: var(--accent); }
|
||||||
|
.radios input { grid-row: span 2; margin: 0; accent-color: var(--accent); }
|
||||||
|
.radios b { font-size: 13px; }
|
||||||
|
.radios small { grid-column: 2; color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.input-suffix { position: relative; }
|
||||||
|
.input-suffix input { padding-right: 40px; }
|
||||||
|
.input-suffix b {
|
||||||
|
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
||||||
|
font-size: 12px; color: var(--muted); font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||||
|
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 14px; }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||||||
|
.actions.wrap { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap;
|
||||||
|
margin-top: 14px; padding: 12px;
|
||||||
|
background: var(--surface); border: 1px dashed var(--line); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.inline-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.inline-field input, .inline-field select {
|
||||||
|
height: 32px; padding: 0 8px; min-width: 130px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
background: var(--surface); font: inherit; font-size: 13px; color: var(--ink);
|
||||||
|
}
|
||||||
|
.inline-form select[name="package_id"] { min-width: 220px; }
|
||||||
|
|
||||||
|
.form.card, .section.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterbar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||||
|
.filterbar input[type="search"], .filterbar select {
|
||||||
|
height: 34px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
background: var(--surface); font: inherit; color: var(--ink);
|
||||||
|
}
|
||||||
|
.filterbar input[type="search"] { flex: 1 1 260px; max-width: 420px; }
|
||||||
|
|
||||||
|
/* ---------- 认证页 ---------- */
|
||||||
|
|
||||||
|
.auth { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; }
|
||||||
|
.auth-card {
|
||||||
|
width: 100%; max-width: 380px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-top: 3px solid var(--accent);
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 18px 40px -32px rgba(26, 28, 33, 0.5);
|
||||||
|
}
|
||||||
|
.auth-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; letter-spacing: 0.06em; margin-bottom: 18px; }
|
||||||
|
.auth-card h1 { font-size: 20px; margin-bottom: 4px; }
|
||||||
|
.auth-card > p { font-size: 12px; margin: 0 0 18px; }
|
||||||
|
.auth-foot { margin-top: 16px !important; text-align: center; }
|
||||||
|
.form .btn-block { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ---------- 日历 ---------- */
|
||||||
|
|
||||||
|
.cal-page { max-width: 1400px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.cal-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.cal-title { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.cal-title h1 { font-size: 20px; font-family: var(--mono); letter-spacing: 0.02em; }
|
||||||
|
.cal-stats { display: flex; align-items: center; gap: 16px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
|
||||||
|
.cal-stats em {
|
||||||
|
font-family: var(--mono); font-style: normal; font-size: 16px;
|
||||||
|
color: var(--ink); margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-body { display: grid; grid-template-columns: minmax(0, 1fr) 372px; gap: 16px; align-items: start; }
|
||||||
|
|
||||||
|
.cal-grid {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cal-dow {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
background: var(--paper);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.cal-dow span {
|
||||||
|
padding: 7px 0; text-align: center;
|
||||||
|
font-size: 12px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.cal-week { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||||
|
|
||||||
|
.cal-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
min-height: 112px; padding: 6px 7px 8px;
|
||||||
|
border-right: 1px solid var(--line-2);
|
||||||
|
border-bottom: 1px solid var(--line-2);
|
||||||
|
color: var(--ink); text-decoration: none;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.cal-cell:nth-child(7n) { border-right: 0; }
|
||||||
|
.cal-cell:hover { background: #f2f7f4; text-decoration: none; }
|
||||||
|
.cal-cell.out { opacity: 0.4; }
|
||||||
|
.cal-cell.weekend .dnum { color: var(--muted); }
|
||||||
|
.cal-cell.sel { box-shadow: inset 0 0 0 2px var(--accent); z-index: 2; }
|
||||||
|
.cal-cell.today .dnum {
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
border-radius: 50%; width: 22px; height: 22px;
|
||||||
|
display: inline-grid; place-items: center;
|
||||||
|
}
|
||||||
|
.cal-cell.has-due::after {
|
||||||
|
content: ""; position: absolute; left: 6px; right: 6px; bottom: 3px;
|
||||||
|
border-bottom: 1px dashed rgba(180, 35, 31, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-cell.heat-1 { background: rgba(47, 93, 80, 0.045); }
|
||||||
|
.cal-cell.heat-2 { background: rgba(47, 93, 80, 0.085); }
|
||||||
|
.cal-cell.heat-3 { background: rgba(47, 93, 80, 0.13); }
|
||||||
|
.cal-cell.heat-4 { background: rgba(47, 93, 80, 0.19); }
|
||||||
|
.cal-cell.heat-0:hover, .cal-cell.heat-1:hover { background: #f2f7f4; }
|
||||||
|
|
||||||
|
.cell-top { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
||||||
|
.dnum { font-family: var(--mono); font-size: 13px; font-weight: 700; line-height: 1.4; }
|
||||||
|
.due-badge {
|
||||||
|
display: inline-flex; align-items: baseline; gap: 2px;
|
||||||
|
font-size: 10px; padding: 0 5px; line-height: 16px;
|
||||||
|
background: var(--danger); color: #fff; border-radius: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.due-badge em { font-style: normal; opacity: 0.85; }
|
||||||
|
.cell-load { font-family: var(--mono); font-size: 10px; color: var(--accent); }
|
||||||
|
|
||||||
|
.cell-tickets { display: flex; flex-direction: column; gap: 2px; margin-top: auto; overflow: hidden; }
|
||||||
|
|
||||||
|
.ticket {
|
||||||
|
display: inline-flex; align-items: baseline; gap: 3px;
|
||||||
|
max-width: 100%; min-width: 0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10.5px; line-height: 16px;
|
||||||
|
border-left: 3px solid var(--tag, var(--accent));
|
||||||
|
background: color-mix(in srgb, var(--tag, var(--accent)) 11%, #fff);
|
||||||
|
border-radius: 1px 2px 2px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.ticket b { font-family: var(--mono); font-size: 11px; color: var(--ink); flex: 0 0 auto; }
|
||||||
|
.ticket .tk-name { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.ticket i { font-style: normal; font-size: 9.5px; color: var(--muted); flex: 0 0 auto; }
|
||||||
|
.ticket.done { opacity: 0.55; }
|
||||||
|
.ticket.done::after { content: "✓"; color: var(--accent); font-size: 10px; }
|
||||||
|
.ticket.skipped { opacity: 0.4; text-decoration: line-through; }
|
||||||
|
.ticket.more { border-left-color: var(--line); background: var(--paper); color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---------- 当日抽屉 ---------- */
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
position: sticky; top: 72px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.drawer-head {
|
||||||
|
position: sticky; top: 0; z-index: 2;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--paper); border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.drawer-head h2 { font-size: 14px; font-family: var(--mono); }
|
||||||
|
.drawer-tags { display: flex; gap: 4px; }
|
||||||
|
.drawer-block { padding: 12px 14px; border-bottom: 1px solid var(--line-2); }
|
||||||
|
.drawer-block:last-child { border-bottom: 0; }
|
||||||
|
.drawer-block h3 {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: var(--ink-2); margin-bottom: 8px;
|
||||||
|
text-transform: none; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.drawer-block h3 em {
|
||||||
|
font-family: var(--mono); font-style: normal; font-size: 11px;
|
||||||
|
background: var(--paper); border: 1px solid var(--line);
|
||||||
|
border-radius: 999px; padding: 0 6px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.drawer-block p { margin: 0; font-size: 12px; }
|
||||||
|
|
||||||
|
.due-list, .task-list, .mini-due, .checkin-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
.due-list li {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 6px 0; border-bottom: 1px dashed var(--line-2);
|
||||||
|
}
|
||||||
|
.due-list li:last-child { border-bottom: 0; }
|
||||||
|
.due-main { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--ink); }
|
||||||
|
.due-main .pkg, .task-meta .pkg { font-size: 11px; color: var(--muted); }
|
||||||
|
.due-main .rem { font-family: var(--mono); font-size: 11px; color: var(--danger); }
|
||||||
|
|
||||||
|
.task { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px dashed var(--line-2); }
|
||||||
|
.task:last-child { border-bottom: 0; }
|
||||||
|
.task-main { min-width: 0; flex: 1 1 auto; }
|
||||||
|
.task-title { display: flex; align-items: center; gap: 6px; color: var(--ink); }
|
||||||
|
.task-meta { display: flex; gap: 6px; font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.task-note { font-size: 11px; color: var(--ink-2); margin-top: 2px; }
|
||||||
|
.task-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; max-width: 170px; }
|
||||||
|
.task.done .task-title, .task.skipped .task-title { opacity: 0.5; }
|
||||||
|
.task.skipped .task-title .ticket { text-decoration: line-through; }
|
||||||
|
|
||||||
|
.mini-due li { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; padding: 4px 0; font-size: 12px; }
|
||||||
|
.sm-date { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---------- 状态标签 ---------- */
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
height: 20px; padding: 0 7px;
|
||||||
|
font-size: 11px; line-height: 1;
|
||||||
|
border-radius: 2px; border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip-active { background: var(--accent-soft); color: var(--accent-ink); border-color: rgba(47, 93, 80, 0.2); }
|
||||||
|
.chip-pending { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
|
||||||
|
.chip-paused { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
|
||||||
|
.chip-expired { background: var(--danger-soft); color: var(--danger); border-color: rgba(180, 35, 31, 0.22); }
|
||||||
|
.chip-usedup { background: var(--warn-soft); color: var(--warn); border-color: rgba(168, 94, 18, 0.22); }
|
||||||
|
.chip-closed { background: #f0efeb; color: var(--muted); border-color: var(--line); }
|
||||||
|
.chip-plain { background: var(--paper); color: var(--ink-2); border-color: var(--line); }
|
||||||
|
|
||||||
|
.tagchip {
|
||||||
|
display: inline-block; margin-right: 4px;
|
||||||
|
padding: 0 6px; height: 19px; line-height: 19px;
|
||||||
|
font-size: 11px; color: var(--ink-2);
|
||||||
|
background: var(--paper); border: 1px solid var(--line); border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due { font-size: 11px; }
|
||||||
|
.due-today { color: var(--danger); font-weight: 700; }
|
||||||
|
.due-warn { color: var(--warn); }
|
||||||
|
.due-expired { color: var(--muted); }
|
||||||
|
.due-ok, .due-none { color: var(--muted); }
|
||||||
|
.plus { color: var(--accent); font-family: var(--mono); }
|
||||||
|
|
||||||
|
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
|
||||||
|
.dot.big { width: 12px; height: 12px; margin: 0; }
|
||||||
|
|
||||||
|
/* ---------- 表格 ---------- */
|
||||||
|
|
||||||
|
.table-wrap { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); overflow-x: auto; }
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.table th {
|
||||||
|
text-align: left; padding: 9px 12px;
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: var(--paper);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table td { padding: 9px 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
|
||||||
|
.table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.table tbody tr:hover { background: #fbfcfb; }
|
||||||
|
.table.tight td, .table.tight th { padding: 7px 10px; }
|
||||||
|
.table .col-dot { width: 28px; }
|
||||||
|
.table tr.archived { opacity: 0.6; }
|
||||||
|
.actions-cell { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.tail {
|
||||||
|
display: inline-block; font-family: var(--mono); font-size: 15px; font-weight: 700;
|
||||||
|
color: var(--accent-ink); letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.name { font-weight: 600; color: var(--ink); }
|
||||||
|
.summary { font-size: 12px; color: var(--ink-2); }
|
||||||
|
|
||||||
|
.pager { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 16px; }
|
||||||
|
.pager-info { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||||
|
padding: 56px 20px; text-align: center;
|
||||||
|
border: 1px dashed var(--line); border-radius: var(--radius);
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
.empty p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
|
.empty-mark {
|
||||||
|
width: 46px; height: 30px; display: grid; place-items: center;
|
||||||
|
border: 1px solid var(--line); border-left: 4px solid var(--accent);
|
||||||
|
background: var(--surface); color: var(--muted); font-size: 12px;
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 客户详情 ---------- */
|
||||||
|
|
||||||
|
.cust-head { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: start; margin-bottom: 16px; }
|
||||||
|
.cust-id { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.cust-tail {
|
||||||
|
font-family: var(--mono); font-size: 30px; font-weight: 700; line-height: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.cust-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
|
||||||
|
.cust-meta { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0; }
|
||||||
|
.cust-meta dt { font-size: 11px; color: var(--muted); }
|
||||||
|
.cust-meta dd { margin: 0; font-size: 12px; color: var(--ink-2); }
|
||||||
|
|
||||||
|
.section { margin-top: 22px; }
|
||||||
|
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.section-head h2 { font-size: 14px; }
|
||||||
|
.section-head .muted { font-size: 11px; }
|
||||||
|
.section.card h2 { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.subgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); gap: 12px; }
|
||||||
|
|
||||||
|
.subcard {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 4px solid var(--tag, var(--accent));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.subcard-head { display: flex; align-items: center; gap: 6px; padding: 10px 12px 6px; }
|
||||||
|
.subcard-head h3 { font-size: 13px; flex: 1 1 auto; }
|
||||||
|
.subcard-head .unit { font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 2px; padding: 0 4px; }
|
||||||
|
.subcard-body { padding: 0 12px 10px; }
|
||||||
|
.subcard-big { font-family: var(--mono); font-size: 20px; font-weight: 700; letter-spacing: 0.01em; }
|
||||||
|
.subcard-meta { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.bar { height: 5px; background: var(--line-2); border-radius: 999px; overflow: hidden; margin-top: 8px; }
|
||||||
|
.bar i { display: block; height: 100%; background: var(--tag, var(--accent)); }
|
||||||
|
.subcard-foot {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
padding: 8px 12px; border-top: 1px dashed var(--line);
|
||||||
|
background: #fcfcfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 16px 16px 10px; }
|
||||||
|
.tl-axis { position: relative; height: 18px; margin-left: 92px; margin-right: 150px; border-bottom: 1px solid var(--line-2); }
|
||||||
|
.tl-tick { position: absolute; top: 0; font-family: var(--mono); font-size: 10px; color: var(--muted); transform: translateX(-50%); }
|
||||||
|
.tl-row { display: grid; grid-template-columns: 84px 1fr 142px; gap: 8px; align-items: center; padding: 5px 0; }
|
||||||
|
.tl-name { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tl-track { position: relative; height: 20px; background: var(--line-2); border-radius: 2px; }
|
||||||
|
.tl-bar { position: absolute; top: 4px; height: 12px; background: var(--tag); border-radius: 2px; }
|
||||||
|
.tl-bar.st-paused { opacity: 0.45; }
|
||||||
|
.tl-bar.st-closed { background: var(--muted); opacity: 0.6; }
|
||||||
|
.tl-pause {
|
||||||
|
position: absolute; top: 4px; height: 12px;
|
||||||
|
background: repeating-linear-gradient(135deg, #c9c7c0 0 4px, #e6e4dd 4px 8px);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.tl-due { position: absolute; top: 1px; bottom: 1px; width: 2px; background: var(--danger); }
|
||||||
|
.tl-legend { font-family: var(--mono); font-size: 10px; color: var(--muted); text-align: right; }
|
||||||
|
|
||||||
|
.checkin-list li { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px dashed var(--line-2); font-size: 12px; }
|
||||||
|
.checkin-list li:last-child { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---------- 追加面板 ---------- */
|
||||||
|
|
||||||
|
.extend {
|
||||||
|
margin-top: 18px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 12px 32px -26px rgba(26, 28, 33, 0.8);
|
||||||
|
scroll-margin-top: 80px;
|
||||||
|
}
|
||||||
|
.drawer .extend { margin: 12px 14px; }
|
||||||
|
.extend-head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-bottom: 1px solid rgba(47, 93, 80, 0.2);
|
||||||
|
}
|
||||||
|
.extend-head h3 { font-size: 14px; }
|
||||||
|
.extend-head p { margin: 2px 0 0; font-size: 11px; }
|
||||||
|
.extend-close { font-size: 20px; line-height: 1; color: var(--ink-2); padding: 0 4px; }
|
||||||
|
.extend-form { padding: 14px; }
|
||||||
|
|
||||||
|
.extend-preview {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px;
|
||||||
|
padding: 10px; margin-bottom: 10px;
|
||||||
|
background: var(--paper); border: 1px dashed var(--line); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.extend-preview div { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.extend-preview span { font-size: 10px; color: var(--muted); }
|
||||||
|
.extend-preview b { font-family: var(--mono); font-size: 13px; font-weight: 600; }
|
||||||
|
.extend-preview b i { font-style: normal; font-size: 18px; color: var(--accent); }
|
||||||
|
.extend-preview .add b { color: var(--accent); }
|
||||||
|
.extend-hint { margin: 0 0 10px; font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
.minis { display: grid; grid-template-columns: repeat(auto-fit, minmax(196px, 1fr)); gap: 12px; margin-bottom: 12px; }
|
||||||
|
.minimonth { border: 1px solid var(--line); border-radius: var(--radius); padding: 8px; }
|
||||||
|
.minimonth-title { font-size: 11px; color: var(--ink-2); font-weight: 600; margin-bottom: 6px; text-align: center; }
|
||||||
|
.minimonth-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||||
|
.minimonth-grid .dow { text-align: center; font-size: 10px; color: var(--muted); }
|
||||||
|
.miniday {
|
||||||
|
height: 28px; border: 1px solid transparent; border-radius: 2px;
|
||||||
|
background: transparent; font: inherit; font-size: 11px; font-family: var(--mono);
|
||||||
|
color: var(--ink-2); cursor: pointer; padding: 0;
|
||||||
|
}
|
||||||
|
.miniday:hover:not(:disabled) { background: var(--accent-soft); border-color: rgba(47, 93, 80, 0.3); }
|
||||||
|
.miniday.out { color: #c3c1ba; }
|
||||||
|
.miniday.old { background: var(--warn-soft); border-color: rgba(168, 94, 18, 0.35); font-weight: 700; }
|
||||||
|
.miniday.today { outline: 1px solid var(--accent); }
|
||||||
|
.miniday.picked { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 700; }
|
||||||
|
.miniday:disabled { color: #d6d4cc; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ---------- 设置页杂项 ---------- */
|
||||||
|
|
||||||
|
.kv { display: grid; gap: 6px; margin: 0 0 14px; }
|
||||||
|
.kv > div { display: grid; grid-template-columns: 88px 1fr; gap: 10px; align-items: baseline; }
|
||||||
|
.kv dt { font-size: 11px; color: var(--muted); }
|
||||||
|
.kv dd { margin: 0; font-size: 12px; word-break: break-all; }
|
||||||
|
.backups { margin-top: 12px; font-size: 12px; }
|
||||||
|
.backups summary { cursor: pointer; color: var(--ink-2); }
|
||||||
|
.backups ul { margin: 8px 0 0; padding-left: 18px; color: var(--muted); }
|
||||||
|
|
||||||
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 14px; flex-wrap: wrap; }
|
||||||
|
.tabs a {
|
||||||
|
padding: 7px 12px; font-size: 13px; color: var(--ink-2);
|
||||||
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tabs a:hover { text-decoration: none; color: var(--ink); }
|
||||||
|
.tabs a.on { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---------- 响应式 ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.cal-body { grid-template-columns: 1fr; }
|
||||||
|
.drawer { position: static; max-height: none; }
|
||||||
|
.cust-head { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.topbar { height: auto; flex-wrap: wrap; padding: 8px 12px; gap: 8px; }
|
||||||
|
.search { order: 3; flex-basis: 100%; max-width: none; }
|
||||||
|
.nav { order: 4; flex-basis: 100%; overflow-x: auto; }
|
||||||
|
.topbar-right { order: 2; margin-left: auto; }
|
||||||
|
main { padding: 0 12px 32px; }
|
||||||
|
.cal-grid { overflow-x: auto; }
|
||||||
|
.cal-week, .cal-dow { min-width: 680px; }
|
||||||
|
.cal-cell { min-height: 96px; }
|
||||||
|
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||||
|
.cust-tail { font-size: 24px; }
|
||||||
|
.tl-row { grid-template-columns: 70px 1fr; }
|
||||||
|
.tl-legend { display: none; }
|
||||||
|
.tl-axis { margin-right: 0; }
|
||||||
|
.extend-preview { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.pill { display: none; }
|
||||||
|
.cal-week, .cal-dow { min-width: 0; }
|
||||||
|
.cal-cell { min-height: 64px; padding: 3px; }
|
||||||
|
.dnum { font-size: 12px; }
|
||||||
|
.due-badge { font-size: 9px; padding: 0 4px; line-height: 14px; }
|
||||||
|
.due-badge em { display: none; }
|
||||||
|
.cell-load { display: none; }
|
||||||
|
.cell-tickets { display: none; }
|
||||||
|
.task { flex-direction: column; }
|
||||||
|
.task-actions { max-width: none; justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/* 代打台 · 交互增强(无框架,全部渐进增强) */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* 提示条自动淡出 */
|
||||||
|
document.querySelectorAll('[data-flash]').forEach(function (el) {
|
||||||
|
setTimeout(function () {
|
||||||
|
el.classList.add('hide');
|
||||||
|
setTimeout(function () { el.remove(); }, 450);
|
||||||
|
}, 4200);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 按 / 聚焦搜索框 */
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
var tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
|
||||||
|
var s = document.querySelector('[data-search]');
|
||||||
|
if (s) { e.preventDefault(); s.focus(); s.select(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 危险操作确认 */
|
||||||
|
document.querySelectorAll('form[data-confirm]').forEach(function (f) {
|
||||||
|
f.addEventListener('submit', function (e) {
|
||||||
|
if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 追加时长面板:点日历挑终止日 + 实时反算 */
|
||||||
|
function parseDay(s) { return new Date(s + 'T00:00:00'); }
|
||||||
|
function diffDays(a, b) { return Math.round((b.getTime() - a.getTime()) / 86400000); }
|
||||||
|
|
||||||
|
document.querySelectorAll('form[data-extend]').forEach(function (form) {
|
||||||
|
var base = parseDay(form.getAttribute('data-base'));
|
||||||
|
var today = parseDay(form.getAttribute('data-today'));
|
||||||
|
var input = form.querySelector('[data-end-input]');
|
||||||
|
var endLabel = form.querySelector('[data-end-label]');
|
||||||
|
var daysEl = form.querySelector('[data-days]');
|
||||||
|
var remainEl = form.querySelector('[data-remain]');
|
||||||
|
var buttons = form.querySelectorAll('.miniday');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
function paint(date) {
|
||||||
|
if (!date) return;
|
||||||
|
var d = parseDay(date);
|
||||||
|
input.value = date;
|
||||||
|
if (endLabel) endLabel.textContent = date;
|
||||||
|
var days = diffDays(base, d) + 1;
|
||||||
|
if (daysEl) daysEl.textContent = days > 0 ? days : 0;
|
||||||
|
var remain = diffDays(today, d) + 1;
|
||||||
|
if (remainEl) remainEl.textContent = remain > 0 ? remain : 0;
|
||||||
|
buttons.forEach(function (b) {
|
||||||
|
b.classList.toggle('picked', b.getAttribute('data-date') === date);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
if (!b.disabled) paint(b.getAttribute('data-date'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
input.addEventListener('change', function () { paint(input.value); });
|
||||||
|
if (input.value) paint(input.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 新订阅表单:按套餐类型切换「天数 / 次数 / 终止日」 */
|
||||||
|
document.querySelectorAll('form[data-sub-form]').forEach(function (form) {
|
||||||
|
var sel = form.querySelector('[data-pkg-select]');
|
||||||
|
var daysWrap = form.querySelector('[data-days-wrap]');
|
||||||
|
var countWrap = form.querySelector('[data-count-wrap]');
|
||||||
|
var endWrap = form.querySelector('[data-end-wrap]');
|
||||||
|
if (!sel) return;
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var opt = sel.options[sel.selectedIndex];
|
||||||
|
var unit = opt && opt.getAttribute('data-unit');
|
||||||
|
var amount = opt && opt.getAttribute('data-amount');
|
||||||
|
if (unit === 'count') {
|
||||||
|
if (daysWrap) daysWrap.hidden = true;
|
||||||
|
if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; }
|
||||||
|
if (endWrap) endWrap.hidden = true;
|
||||||
|
} 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) {
|
||||||
|
var label = form.querySelector('[data-unit-label]');
|
||||||
|
var radios = form.querySelectorAll('input[name="unit"]');
|
||||||
|
function sync() {
|
||||||
|
var v = 'day';
|
||||||
|
radios.forEach(function (r) { if (r.checked) v = r.value; });
|
||||||
|
if (label) label.textContent = v === 'count' ? '次' : '天';
|
||||||
|
}
|
||||||
|
radios.forEach(function (r) { r.addEventListener('change', sync); });
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect width="64" height="64" rx="6" fill="#2F5D50"/>
|
||||||
|
<rect x="6" y="14" width="52" height="36" rx="3" fill="none" stroke="#F7F6F2" stroke-width="3"/>
|
||||||
|
<path d="M10 34h44" stroke="#F7F6F2" stroke-width="2" stroke-dasharray="4 4" opacity="0.55"/>
|
||||||
|
<rect x="12" y="20" width="14" height="7" fill="#F7F6F2"/>
|
||||||
|
<rect x="32" y="20" width="20" height="3" fill="#F7F6F2" opacity="0.8"/>
|
||||||
|
<rect x="32" y="26" width="12" height="3" fill="#F7F6F2" opacity="0.5"/>
|
||||||
|
<rect x="12" y="42" width="24" height="3" fill="#F7F6F2" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 627 B |
@@ -0,0 +1,212 @@
|
|||||||
|
/* ============================================================
|
||||||
|
代打台 · 可视化大屏(深色「记分板」)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #070a0f;
|
||||||
|
--panel: #0e141c;
|
||||||
|
--panel-2: #121a24;
|
||||||
|
--line: #1b2530;
|
||||||
|
--ink: #e8eef2;
|
||||||
|
--dim: #7d8b99;
|
||||||
|
--mint: #35e0c6;
|
||||||
|
--mint-dim: rgba(53, 224, 198, 0.14);
|
||||||
|
--amber: #f5a524;
|
||||||
|
--red: #f2555a;
|
||||||
|
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
|
||||||
|
--sans: "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
|
||||||
|
body.screen-body {
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 15% -10%, rgba(53, 224, 198, 0.07), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, rgba(245, 165, 36, 0.05), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--mint); text-decoration: none; }
|
||||||
|
a:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
.scr {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: clamp(12px, 1.4vw, 22px) clamp(14px, 1.8vw, 28px);
|
||||||
|
gap: clamp(10px, 1.1vw, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scr-head { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.scr-brand {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-size: clamp(15px, 1.2vw, 19px); font-weight: 700; letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.scr-brand .mark {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 30px; height: 30px; border-radius: 3px;
|
||||||
|
background: var(--mint); color: #04211d; font-size: 15px;
|
||||||
|
box-shadow: 0 0 22px rgba(53, 224, 198, 0.45);
|
||||||
|
}
|
||||||
|
.scr-brand em { font-style: normal; color: var(--dim); font-weight: 400; letter-spacing: 0.24em; font-size: 0.7em; }
|
||||||
|
|
||||||
|
.scr-clock { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; }
|
||||||
|
.scr-clock b {
|
||||||
|
font-family: var(--mono); font-size: clamp(24px, 2.4vw, 36px);
|
||||||
|
font-weight: 600; line-height: 1;
|
||||||
|
color: var(--ink); letter-spacing: 0.04em;
|
||||||
|
text-shadow: 0 0 22px rgba(53, 224, 198, 0.25);
|
||||||
|
}
|
||||||
|
.scr-clock span { font-family: var(--mono); font-size: 11px; color: var(--dim); letter-spacing: 0.1em; }
|
||||||
|
.scr-links { font-size: 11px; color: var(--dim); }
|
||||||
|
|
||||||
|
/* KPI */
|
||||||
|
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(8px, 0.9vw, 14px); }
|
||||||
|
.kpi {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, var(--panel-2), var(--panel));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: clamp(10px, 1vw, 16px) clamp(12px, 1.2vw, 20px);
|
||||||
|
}
|
||||||
|
.kpi::before {
|
||||||
|
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
|
||||||
|
background: var(--mint); opacity: 0.75;
|
||||||
|
}
|
||||||
|
.kpi-warm::before { background: var(--amber); }
|
||||||
|
.kpi-hot::before { background: var(--red); }
|
||||||
|
.kpi span { display: block; font-size: 11px; color: var(--dim); letter-spacing: 0.16em; }
|
||||||
|
.kpi b {
|
||||||
|
font-family: var(--mono); font-size: clamp(28px, 3.4vw, 52px); font-weight: 700;
|
||||||
|
line-height: 1.15; letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.kpi i { font-style: normal; font-size: 11px; color: var(--dim); margin-left: 4px; }
|
||||||
|
.kpi-hot b { color: var(--red); }
|
||||||
|
.kpi-warm b { color: var(--amber); }
|
||||||
|
|
||||||
|
/* 主区 */
|
||||||
|
.scr-main { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); flex: 1 1 auto; }
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: clamp(10px, 1vw, 16px);
|
||||||
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
|
}
|
||||||
|
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0; font-size: clamp(12px, 0.95vw, 14px); font-weight: 600; letter-spacing: 0.08em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.panel-head h2::before {
|
||||||
|
content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 7px;
|
||||||
|
background: var(--mint); vertical-align: 2px;
|
||||||
|
}
|
||||||
|
.panel-head span { font-size: 11px; color: var(--dim); font-family: var(--mono); }
|
||||||
|
|
||||||
|
/* 热力月历 */
|
||||||
|
.heat-grid { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; }
|
||||||
|
.heat-dow, .heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||||
|
.heat-row { flex: 1 1 0; min-height: 0; }
|
||||||
|
.heat-dow { flex: 0 0 auto; }
|
||||||
|
.heat-dow span { text-align: center; font-size: 10px; color: var(--dim); }
|
||||||
|
.heat-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex; flex-direction: column; justify-content: space-between;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #0a1017;
|
||||||
|
}
|
||||||
|
.heat-cell span { font-family: var(--mono); font-size: 10px; color: var(--dim); }
|
||||||
|
.heat-cell b {
|
||||||
|
align-self: flex-end; font-family: var(--mono); font-size: clamp(12px, 1.1vw, 16px);
|
||||||
|
color: var(--mint); font-weight: 700;
|
||||||
|
}
|
||||||
|
.heat-cell.out { opacity: 0.28; }
|
||||||
|
.heat-cell.today { border-color: var(--mint); box-shadow: 0 0 0 1px rgba(53, 224, 198, 0.35), 0 0 18px rgba(53, 224, 198, 0.16); }
|
||||||
|
.heat-cell.h1 { background: rgba(53, 224, 198, 0.08); border-color: rgba(53, 224, 198, 0.22); }
|
||||||
|
.heat-cell.h2 { background: rgba(53, 224, 198, 0.15); border-color: rgba(53, 224, 198, 0.3); }
|
||||||
|
.heat-cell.h3 { background: rgba(53, 224, 198, 0.24); border-color: rgba(53, 224, 198, 0.42); }
|
||||||
|
.heat-cell.h4 { background: rgba(53, 224, 198, 0.36); border-color: rgba(53, 224, 198, 0.6); }
|
||||||
|
.heat-cell.h3 b, .heat-cell.h4 b { color: #eafffb; }
|
||||||
|
.heat-legend { display: flex; align-items: center; gap: 5px; margin-top: 10px; font-size: 10px; color: var(--dim); flex: 0 0 auto; }
|
||||||
|
.heat-legend i { width: 18px; height: 9px; border-radius: 2px; border: 1px solid #26313d; }
|
||||||
|
.heat-legend .h0 { background: #0a1017; }
|
||||||
|
.heat-legend .h1 { background: rgba(53, 224, 198, 0.16); }
|
||||||
|
.heat-legend .h2 { background: rgba(53, 224, 198, 0.28); }
|
||||||
|
.heat-legend .h3 { background: rgba(53, 224, 198, 0.42); }
|
||||||
|
.heat-legend .h4 { background: rgba(53, 224, 198, 0.6); }
|
||||||
|
|
||||||
|
/* TOP10 */
|
||||||
|
.top-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow: hidden; }
|
||||||
|
.top-item {
|
||||||
|
display: grid; grid-template-columns: 22px auto 1fr auto auto;
|
||||||
|
grid-template-areas: "rank tail name pkg remain" "rank bar bar bar bar";
|
||||||
|
column-gap: 8px; row-gap: 3px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #0a1017;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.top-item .rank { grid-area: rank; font-family: var(--mono); font-size: 11px; color: var(--dim); }
|
||||||
|
.top-tail { grid-area: tail; font-family: var(--mono); font-size: clamp(13px, 1.15vw, 17px); font-weight: 700; letter-spacing: 0.03em; }
|
||||||
|
.top-name { grid-area: name; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.top-pkg {
|
||||||
|
grid-area: pkg; font-size: 10px; color: var(--dim);
|
||||||
|
border-left: 2px solid var(--tag); padding-left: 5px;
|
||||||
|
}
|
||||||
|
.top-remain { grid-area: remain; font-family: var(--mono); font-size: 12px; color: var(--mint); }
|
||||||
|
.top-bar { grid-area: bar; height: 3px; background: #16202b; border-radius: 999px; overflow: hidden; }
|
||||||
|
.top-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--mint), rgba(53, 224, 198, 0.35)); }
|
||||||
|
.top-item.lv-warn { border-left-color: var(--amber); }
|
||||||
|
.top-item.lv-warn .top-remain { color: var(--amber); }
|
||||||
|
.top-item.lv-warn .top-bar i { background: linear-gradient(90deg, var(--amber), rgba(245, 165, 36, 0.3)); }
|
||||||
|
.top-item.lv-today, .top-item.lv-expired { border-left-color: var(--red); }
|
||||||
|
.top-item.lv-today .top-remain, .top-item.lv-expired .top-remain { color: var(--red); }
|
||||||
|
.top-item.lv-today .top-bar i, .top-item.lv-expired .top-bar i { background: linear-gradient(90deg, var(--red), rgba(242, 85, 90, 0.3)); }
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.scr-bottom { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); }
|
||||||
|
.today-list, .recent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 22vh; overflow: hidden; }
|
||||||
|
.today-list li {
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
|
||||||
|
padding: 4px 6px; border-bottom: 1px solid rgba(27, 37, 48, 0.6);
|
||||||
|
}
|
||||||
|
.tchip {
|
||||||
|
display: inline-flex; align-items: baseline; gap: 3px;
|
||||||
|
padding: 1px 6px; font-size: 11px;
|
||||||
|
border-left: 3px solid var(--tag);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 1px 2px 2px 1px;
|
||||||
|
}
|
||||||
|
.tchip b { font-family: var(--mono); font-size: 12px; color: var(--ink); }
|
||||||
|
.today-list .pkg, .recent-list .pkg { font-size: 11px; color: var(--dim); }
|
||||||
|
.today-list .rem { font-family: var(--mono); font-size: 11px; color: var(--mint); }
|
||||||
|
|
||||||
|
.recent-list li { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 11px; border-bottom: 1px solid rgba(27, 37, 48, 0.6); }
|
||||||
|
.recent-list .time { color: var(--dim); font-size: 10px; }
|
||||||
|
.recent-list .who b { font-family: var(--mono); font-size: 12px; margin-right: 4px; }
|
||||||
|
.recent-list .plus { margin-left: auto; color: var(--mint); font-family: var(--mono); font-style: normal; }
|
||||||
|
.recent-list .op { color: var(--dim); }
|
||||||
|
|
||||||
|
.scr-foot { font-size: 10px; color: #47535f; text-align: right; letter-spacing: 0.12em; }
|
||||||
|
.scr-empty { margin: auto; color: var(--dim); font-size: 12px; text-align: center; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.scr-kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.scr-main, .scr-bottom { grid-template-columns: 1fr; }
|
||||||
|
.heat-cell { min-height: 34px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* 大屏:时钟 + 全屏快捷键 */
|
||||||
|
(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{define "layout"}}<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} · 代打台</title>
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{if .User}}
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/calendar"><span class="brand-mark">代</span><span class="brand-name">代打台</span></a>
|
||||||
|
<form class="search" action="/customers" method="get" role="search">
|
||||||
|
<input type="search" name="q" value="{{.Query}}" placeholder="手机尾号 / 姓名 / 游戏名" aria-label="搜索客户" autocomplete="off" data-search>
|
||||||
|
<kbd>/</kbd>
|
||||||
|
</form>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/calendar" class="{{if eq .Nav "calendar"}}on{{end}}">日历</a>
|
||||||
|
<a href="/customers" class="{{if eq .Nav "customers"}}on{{end}}">客户</a>
|
||||||
|
<a href="/subscriptions" class="{{if eq .Nav "subscriptions"}}on{{end}}">订阅</a>
|
||||||
|
<a href="/packages" class="{{if eq .Nav "packages"}}on{{end}}">套餐</a>
|
||||||
|
<a href="/screen" target="_blank" rel="noopener" class="nav-screen">大屏 ↗</a>
|
||||||
|
</nav>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<a class="pill pill-due" href="/subscriptions?state=due_today" title="今天到期的订阅">今日到期 <b>{{.DueToday}}</b></a>
|
||||||
|
<a class="pill pill-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a>
|
||||||
|
<div class="me">
|
||||||
|
<a href="/settings">{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}</a>
|
||||||
|
<form method="post" action="/logout"><button type="submit" class="link-btn">退出</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
|
{{if .Flash}}<div class="flash" data-flash>{{.Flash}}</div>{{end}}
|
||||||
|
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
|
||||||
|
<main{{if .User}} class="with-top"{{end}}>{{template "content" .}}</main>
|
||||||
|
{{if .User}}<footer class="foot">代打台 v{{.Version}} · <a href="/logs">操作日志</a> · <a href="/settings">设置</a> · <a href="/screen" target="_blank" rel="noopener">大屏</a></footer>{{end}}
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{{define "state-chip"}}<span class="chip chip-{{.StateClass}}">{{.StateLabel}}</span>{{end}}
|
||||||
|
|
||||||
|
{{define "pager"}}
|
||||||
|
{{if gt .Pages 1}}
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .Prev}}<a class="btn btn-sm" href="{{.Prev}}">← 上一页</a>{{else}}<span class="btn btn-sm disabled">← 上一页</span>{{end}}
|
||||||
|
<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}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "empty"}}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-mark">票</div>
|
||||||
|
<p>{{.Text}}</p>
|
||||||
|
{{if .Action}}<a class="btn btn-primary" href="{{.Action}}">{{.ActionText}}</a>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "sub-card"}}
|
||||||
|
<article class="subcard" id="sub-{{.ID}}" style="--tag:{{.PackageColor}}">
|
||||||
|
<div class="subcard-head">
|
||||||
|
<span class="tag"></span>
|
||||||
|
<h3>{{.PackageName}}</h3>
|
||||||
|
{{template "state-chip" .}}
|
||||||
|
<span class="unit">{{.UnitLabel}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="subcard-body">
|
||||||
|
<div class="subcard-big">{{.RemainText}}</div>
|
||||||
|
<div class="subcard-meta">{{.PeriodText}}</div>
|
||||||
|
{{if .DueText}}<div class="due due-{{.Eval.Level}}">{{.DueText}}</div>{{end}}
|
||||||
|
<div class="bar"><i style="width:{{.Progress}}%"></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="subcard-foot">
|
||||||
|
{{if ne .Status "closed"}}
|
||||||
|
{{if .CanCheckinToday}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/checkin">
|
||||||
|
<input type="hidden" name="date" value="{{.Today}}">
|
||||||
|
<input type="hidden" name="status" value="done">
|
||||||
|
<button class="btn btn-sm">今日打卡</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
|
||||||
|
{{if eq .Status "active"}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
|
||||||
|
<button class="btn btn-sm">暂停</button>
|
||||||
|
</form>
|
||||||
|
{{else if eq .Status "paused"}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/resume">
|
||||||
|
<button class="btn btn-sm">恢复</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/close" data-confirm="结束后该订阅不再出现在待打清单,确认结束?">
|
||||||
|
<button class="btn btn-sm btn-ghost">结束</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/reopen">
|
||||||
|
<button class="btn btn-sm">重新启用</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "extend-panel"}}
|
||||||
|
<section class="extend" id="extend" style="--tag:{{.Sub.PackageColor}}">
|
||||||
|
<header class="extend-head">
|
||||||
|
<div>
|
||||||
|
<h3>给 {{.Sub.CustomerTail}} {{.Sub.CustomerName}} 追加{{if .IsDay}}时长{{else}}次数{{end}}</h3>
|
||||||
|
<p class="muted">{{.Sub.PackageName}} · {{.View.RemainText}} · {{.View.PeriodText}}</p>
|
||||||
|
</div>
|
||||||
|
<a class="extend-close" href="{{.Back}}" aria-label="关闭">×</a>
|
||||||
|
</header>
|
||||||
|
{{if .IsDay}}
|
||||||
|
<form method="post" action="/subscriptions/{{.Sub.ID}}/extend" class="extend-form"
|
||||||
|
data-extend data-base="{{.Base}}" data-old-end="{{.OldEnd}}" data-today="{{.Today}}">
|
||||||
|
<input type="hidden" name="back" value="{{.Back}}">
|
||||||
|
<input type="hidden" name="new_end_date" value="{{.Suggested}}" data-end-input>
|
||||||
|
<div class="extend-preview">
|
||||||
|
<div><span>计算起点</span><b data-base-label>{{.Base}}</b></div>
|
||||||
|
<div><span>原结束日</span><b>{{.OldEnd}}</b></div>
|
||||||
|
<div><span>新结束日</span><b data-end-label>{{.Suggested}}</b></div>
|
||||||
|
<div class="add"><span>本次追加</span><b><i data-days>{{.SuggestedDays}}</i> 天</b></div>
|
||||||
|
<div><span>追加后剩余</span><b><i data-remain>—</i> 天</b></div>
|
||||||
|
</div>
|
||||||
|
<p class="extend-hint">在下面的日历上点选新的终止日期;未到期的订阅从原结束日次日起算,已到期的从今天起算。</p>
|
||||||
|
<div class="minis">
|
||||||
|
{{range .Months}}
|
||||||
|
<div class="minimonth">
|
||||||
|
<div class="minimonth-title">{{.Title}}</div>
|
||||||
|
<div class="minimonth-grid">
|
||||||
|
<span class="dow">一</span><span class="dow">二</span><span class="dow">三</span><span class="dow">四</span><span class="dow">五</span><span class="dow">六</span><span class="dow">日</span>
|
||||||
|
{{range .Weeks}}{{range .}}
|
||||||
|
<button type="button" class="miniday{{if not .InMonth}} out{{end}}{{if .IsOldEnd}} old{{end}}{{if .IsToday}} today{{end}}" data-date="{{.Date}}"{{if .Disabled}} disabled{{end}}>{{.Day}}</button>
|
||||||
|
{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<label class="field"><span>备注</span>
|
||||||
|
<input type="text" name="note" maxlength="120" placeholder="例如:微信转账 30 天 / 补送 3 天">
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">确认追加</button>
|
||||||
|
<a class="btn" href="{{.Back}}">取消</a>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "screenlayout"}}<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body class="screen-body">
|
||||||
|
{{template "content" .}}
|
||||||
|
<script src="/static/screen.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="cal-page">
|
||||||
|
<header class="cal-head">
|
||||||
|
<div class="cal-title">
|
||||||
|
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}" aria-label="上个月">‹</a>
|
||||||
|
<h1>{{.D.Title}}</h1>
|
||||||
|
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}" aria-label="下个月">›</a>
|
||||||
|
<a class="btn btn-sm" href="/calendar">回到今天</a>
|
||||||
|
</div>
|
||||||
|
<div class="cal-stats">
|
||||||
|
<span><em>{{.D.MonthDue}}</em>本月到期</span>
|
||||||
|
<span><em>{{.D.MonthTasks}}</em>本月待打</span>
|
||||||
|
<span><em>{{.D.MonthDone}}</em>已完成</span>
|
||||||
|
<a class="btn btn-primary btn-sm" href="/customers/new">+ 新建客户</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .D.NoPackages}}
|
||||||
|
<div class="notice">还没有服务套餐,先去 <a href="/packages/new">新建套餐</a>,再给客户开通。</div>
|
||||||
|
{{else if .D.NoCustomers}}
|
||||||
|
<div class="notice">还没有客户,<a href="/customers/new">新建第一位客户</a>。</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="cal-body">
|
||||||
|
<div class="cal-grid" role="grid">
|
||||||
|
<div class="cal-dow" role="row">
|
||||||
|
{{range .D.Weekdays}}<span>{{.}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{range .D.Weeks}}
|
||||||
|
<div class="cal-week" role="row">
|
||||||
|
{{range .}}
|
||||||
|
<a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}"
|
||||||
|
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}" role="gridcell">
|
||||||
|
<div class="cell-top">
|
||||||
|
<span class="dnum">{{.Day}}</span>
|
||||||
|
{{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .TaskCount}}
|
||||||
|
<div class="cell-load">{{.DoneCount}}/{{.TaskCount}} 单</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="cell-tickets">
|
||||||
|
{{range .Tickets}}
|
||||||
|
<span class="ticket{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}" style="--tag:{{.Color}}"><b>{{.Tail}}</b><span class="tk-name">{{.Name}}</span><i>{{.Pkg}}</i></span>
|
||||||
|
{{end}}
|
||||||
|
{{if gt .More 0}}<span class="ticket more">+{{.More}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="drawer">
|
||||||
|
<header class="drawer-head">
|
||||||
|
<h2>{{.D.SelectedLabel}}</h2>
|
||||||
|
<div class="drawer-tags">
|
||||||
|
{{if .D.SelectedIsToday}}<span class="chip chip-active">今天</span>{{end}}
|
||||||
|
{{if .D.SelectedIsPast}}<span class="chip chip-closed">已过去</span>{{end}}
|
||||||
|
<span class="chip chip-plain">{{len .D.Tasks}} 待打</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drawer-block">
|
||||||
|
<h3>这天到期 <em>{{len .D.Due}}</em></h3>
|
||||||
|
{{if .D.Due}}
|
||||||
|
<ul class="due-list">
|
||||||
|
{{range .D.Due}}
|
||||||
|
<li>
|
||||||
|
<a class="due-main" href="/customers/{{.CustomerID}}">
|
||||||
|
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
|
||||||
|
<span class="pkg">{{.PackageName}}</span>
|
||||||
|
<span class="rem">{{.RemainShort}}</span>
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="muted">没有到期订阅。</p>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-block">
|
||||||
|
<h3>当日待打 <em>{{len .D.Tasks}}</em></h3>
|
||||||
|
{{if .D.Tasks}}
|
||||||
|
<ul class="task-list">
|
||||||
|
{{range .D.Tasks}}
|
||||||
|
<li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}">
|
||||||
|
<div class="task-main">
|
||||||
|
<a class="task-title" href="/customers/{{.CustomerID}}">
|
||||||
|
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
|
||||||
|
<span class="pkg">{{.PackageName}}</span>
|
||||||
|
</a>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span>{{.RemainText}}</span>
|
||||||
|
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Note}}<div class="task-note">{{.Note}}{{if .Operator}} · {{.Operator}}{{end}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
{{if or .Done .Skipped}}
|
||||||
|
<span class="chip {{if .Done}}chip-active{{else}}chip-paused{{end}}">{{if .Done}}已完成{{else}}已跳过{{end}}</span>
|
||||||
|
<form method="post" action="/checkins/{{.CheckinID}}/delete">
|
||||||
|
<input type="hidden" name="back" value="{{$.D.Back}}">
|
||||||
|
<button class="btn btn-sm btn-ghost">撤销</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/checkin">
|
||||||
|
<input type="hidden" name="date" value="{{$.D.Selected}}">
|
||||||
|
<input type="hidden" name="status" value="done">
|
||||||
|
<input type="hidden" name="back" value="{{$.D.Back}}">
|
||||||
|
<button class="btn btn-sm btn-primary">完成</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/checkin">
|
||||||
|
<input type="hidden" name="date" value="{{$.D.Selected}}">
|
||||||
|
<input type="hidden" name="status" value="skipped">
|
||||||
|
<input type="hidden" name="back" value="{{$.D.Back}}">
|
||||||
|
<button class="btn btn-sm btn-ghost">跳过</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="muted">这天没有待打任务。</p>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
|
||||||
|
|
||||||
|
<section class="drawer-block">
|
||||||
|
<h3>近 7 天到期</h3>
|
||||||
|
{{if .D.OtherDue}}
|
||||||
|
<ul class="mini-due">
|
||||||
|
{{range .D.OtherDue}}
|
||||||
|
<li>
|
||||||
|
<a href="/customers/{{.CustomerID}}"><span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span></a>
|
||||||
|
<span class="muted">{{.PackageName}}</span>
|
||||||
|
<span class="sm-date">{{.EndDate}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="muted">7 天内没有其它到期。</p>{{end}}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page">
|
||||||
|
<header class="cust-head">
|
||||||
|
<div class="cust-id">
|
||||||
|
<span class="cust-tail">{{.D.Customer.Tail}}</span>
|
||||||
|
<div>
|
||||||
|
<h1>{{.D.Customer.Name}}</h1>
|
||||||
|
<div class="cust-tags">
|
||||||
|
{{range .D.Customer.TagList}}<span class="tagchip">{{.}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl class="cust-meta">
|
||||||
|
{{if .D.Customer.GameName}}<div><dt>游戏</dt><dd>{{.D.Customer.GameName}}</dd></div>{{end}}
|
||||||
|
{{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}}
|
||||||
|
{{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}}
|
||||||
|
{{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}}
|
||||||
|
<div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a class="btn" href="/customers/{{.D.Customer.ID}}/edit">编辑资料</a>
|
||||||
|
<a class="btn btn-ghost" href="/customers">返回列表</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}}
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>订阅</h2>
|
||||||
|
<span class="muted">同一套餐只能有一条订阅,需要更多时长请用「追加」</span>
|
||||||
|
</div>
|
||||||
|
{{if .D.Subs}}
|
||||||
|
<div class="subgrid">
|
||||||
|
{{range .D.Subs}}{{template "sub-card" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">还没有订阅。用下面的表单为他开通第一个套餐。</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .D.Packages}}
|
||||||
|
<form method="post" action="/customers/{{.D.Customer.ID}}/subscriptions" class="inline-form" data-sub-form>
|
||||||
|
<select name="package_id" required data-pkg-select>
|
||||||
|
<option value="">选择套餐…</option>
|
||||||
|
{{range .D.Packages}}
|
||||||
|
<option value="{{.ID}}" data-unit="{{.Unit}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
|
||||||
|
{{.Name}} · {{.UnitLabel}} {{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{if .Archived}}(已归档){{end}}
|
||||||
|
</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<label class="inline-field">开始日
|
||||||
|
<input type="date" name="start_date" value="{{.TodayStr}}" required>
|
||||||
|
</label>
|
||||||
|
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}}
|
||||||
|
天数 <input type="number" name="days" min="1" max="3650" value="30">
|
||||||
|
</label>
|
||||||
|
<label class="inline-field" data-count-wrap hidden>
|
||||||
|
次数 <input type="number" name="total_count" min="1" max="10000" value="10">
|
||||||
|
</label>
|
||||||
|
<label class="inline-field" data-end-wrap>
|
||||||
|
终止日(可留空)<input type="date" name="end_date">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" type="submit">开通订阅</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">还没有可用套餐,<a href="/packages/new">先新建套餐</a>。</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
|
||||||
|
|
||||||
|
{{if .D.Timeline}}
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>时间轴</h2>
|
||||||
|
<span class="muted">{{.D.Timeline.Start}} → {{.D.Timeline.End}} · 灰色为暂停段</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="tl-axis">
|
||||||
|
{{range .D.Timeline.Ticks}}<span class="tl-tick" style="left:{{pct .Left}}%">{{.Label}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{range .D.Timeline.Bars}}
|
||||||
|
<div class="tl-row">
|
||||||
|
<span class="tl-name">{{.Name}}</span>
|
||||||
|
<div class="tl-track">
|
||||||
|
<i class="tl-bar st-{{.State}}" style="--tag:{{.Color}};left:{{pct .Left}}%;width:{{pct .Width}}%"></i>
|
||||||
|
{{range .Pauses}}<u class="tl-pause" style="left:{{pct .Left}}%;width:{{pct .Width}}%"></u>{{end}}
|
||||||
|
{{if .HasDue}}<em class="tl-due" style="left:{{pct .DueLeft}}%"></em>{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="tl-legend">{{.Label}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>服务时长追加记录</h2>
|
||||||
|
<span class="muted">每次追加都留痕,可作为对账依据</span>
|
||||||
|
</div>
|
||||||
|
{{if .D.Adjustments}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table tight">
|
||||||
|
<thead>
|
||||||
|
<tr><th>时间</th><th>套餐</th><th>类型</th><th>计算起点</th><th>结束日变化</th><th>增量</th><th>备注</th><th>操作人</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .D.Adjustments}}
|
||||||
|
<tr>
|
||||||
|
<td class="mono small">{{.CreatedAt}}</td>
|
||||||
|
<td><span class="dot" style="background:{{index $.D.AdjSubColor .SubID}}"></span>{{index $.D.AdjSubName .SubID}}</td>
|
||||||
|
<td>{{.KindLabel}}</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">
|
||||||
|
{{if gt .DaysAdded 0}}<span class="plus">+{{.DaysAdded}} 天</span>{{end}}
|
||||||
|
{{if gt .CountAdded 0}}<span class="plus">+{{.CountAdded}} 次</span>{{end}}
|
||||||
|
{{if and (le .DaysAdded 0) (le .CountAdded 0)}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
|
||||||
|
<td class="small">{{if .OperatorName}}{{.OperatorName}}{{else}}—{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="muted">还没有追加记录。</p>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>打卡历史</h2>
|
||||||
|
<span class="muted">最近 30 条</span>
|
||||||
|
</div>
|
||||||
|
{{if .D.Checkins}}
|
||||||
|
<ul class="checkin-list">
|
||||||
|
{{range .D.Checkins}}
|
||||||
|
<li>
|
||||||
|
<span class="mono">{{.ServiceDate}}</span>
|
||||||
|
<span class="dot" style="background:{{.SubColor}}"></span>
|
||||||
|
<span>{{.SubName}}</span>
|
||||||
|
<span class="chip {{if eq .Status "done"}}chip-active{{else}}chip-paused{{end}}">{{if eq .Status "done"}}完成{{else}}跳过{{end}}</span>
|
||||||
|
{{if .Note}}<span class="muted small">{{.Note}}</span>{{end}}
|
||||||
|
<span class="muted small">{{if .OperatorName}}{{.OperatorName}}{{end}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}<p class="muted">还没有打卡记录。</p>{{end}}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page narrow">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>{{if .D.IsNew}}新建客户{{else}}编辑客户{{end}}</h1>
|
||||||
|
<p class="muted">手机尾号 + 客户名 唯一;尾号是日常查询的主键。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form method="post" class="form card"
|
||||||
|
action="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}/edit{{end}}">
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field"><span>手机尾号 <i>*</i></span>
|
||||||
|
<input name="tail" value="{{.D.Customer.Tail}}" required maxlength="8" placeholder="例如 1234" autofocus>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>客户名 <i>*</i></span>
|
||||||
|
<input name="name" value="{{.D.Customer.Name}}" required maxlength="32" placeholder="例如 老张">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field"><span>游戏名</span>
|
||||||
|
<input name="game_name" value="{{.D.Customer.GameName}}" maxlength="32" placeholder="例如 王者荣耀">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>区服 / UID</span>
|
||||||
|
<input name="game_server" value="{{.D.Customer.GameServer}}" maxlength="32" placeholder="例如 微信区 123456">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field"><span>联系方式</span>
|
||||||
|
<input name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>来源渠道</span>
|
||||||
|
<input name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="field"><span>标签</span>
|
||||||
|
<input name="tags" value="{{.D.Customer.Tags}}" maxlength="120" placeholder="老客户,包月,难缠(逗号分隔)">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>备注</span>
|
||||||
|
<textarea name="note" rows="3" maxlength="500" placeholder="打法偏好、沟通记录等">{{.D.Customer.Note}}</textarea>
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" type="submit">保存</button>
|
||||||
|
<a class="btn" href="{{if .D.IsNew}}/customers{{else}}/customers/{{.D.Customer.ID}}{{end}}">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>客户</h1>
|
||||||
|
<p class="muted">共 {{.D.Total}} 位 · 输入手机尾号可快速定位</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="filterbar" method="get" action="/customers">
|
||||||
|
<input type="search" name="q" value="{{.D.Q}}" placeholder="手机尾号 / 姓名 / 游戏名 / 联系方式" autocomplete="off">
|
||||||
|
<button class="btn" type="submit">搜索</button>
|
||||||
|
{{if .D.Q}}<a class="btn btn-ghost" href="/customers">清除</a>{{end}}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .D.List}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>尾号</th><th>客户</th><th>联系方式</th><th>订阅与剩余</th><th>标签</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .D.List}}
|
||||||
|
<tr>
|
||||||
|
<td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td>
|
||||||
|
<td>
|
||||||
|
<a class="name" href="/customers/{{.ID}}">{{.Name}}</a>
|
||||||
|
{{if .GameName}}<div class="muted small">{{.GameName}}{{if .GameServer}} · {{.GameServer}}{{end}}</div>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="muted small">{{if .Contact}}{{.Contact}}{{else}}—{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<span class="summary due-{{.Level}}">{{.Summary}}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{range .TagList}}<span class="tagchip">{{.}}</span>{{end}}</td>
|
||||||
|
<td class="right"><a class="btn btn-sm" href="/customers/{{.ID}}">详情</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{template "pager" .D.Pager}}
|
||||||
|
{{else}}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-mark">票</div>
|
||||||
|
{{if .D.Q}}
|
||||||
|
<p>没有匹配「{{.D.Q}}」的客户。</p>
|
||||||
|
<a class="btn" href="/customers">看全部客户</a>
|
||||||
|
{{else}}
|
||||||
|
<p>还没有客户。先建一位,再给他开套餐。</p>
|
||||||
|
<a class="btn btn-primary" href="/customers/new">+ 新建客户</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="auth">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
|
||||||
|
<h1>登录</h1>
|
||||||
|
<p class="muted">所有账户均为管理员,任何操作都会留痕。</p>
|
||||||
|
<form method="post" action="/login" class="form">
|
||||||
|
<input type="hidden" name="next" value="{{.Next}}">
|
||||||
|
<label class="field"><span>用户名</span>
|
||||||
|
<input type="text" name="username" required autofocus autocomplete="username">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>密码</span>
|
||||||
|
<input type="password" name="password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">登录</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-foot muted">代打台 · 单文件部署 · 数据只在本机</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>操作日志</h1>
|
||||||
|
<p class="muted">共 {{.D.Total}} 条 · 谁在什么时候改了什么,都在这里</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="filterbar" method="get" action="/logs">
|
||||||
|
<input type="search" name="q" value="{{.D.Q}}" placeholder="搜索摘要 / 动作 / 操作人" autocomplete="off">
|
||||||
|
<button class="btn" type="submit">搜索</button>
|
||||||
|
{{if .D.Q}}<a class="btn btn-ghost" href="/logs">清除</a>{{end}}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .D.List}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table tight">
|
||||||
|
<thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>摘要</th><th>IP</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .D.List}}
|
||||||
|
<tr>
|
||||||
|
<td class="mono small">{{.CreatedAt}}</td>
|
||||||
|
<td>{{if .OperatorName}}{{.OperatorName}}{{else}}系统{{end}}</td>
|
||||||
|
<td><span class="chip chip-plain">{{.Action}}</span></td>
|
||||||
|
<td class="small muted">{{if .Entity}}{{.Entity}}{{if .EntityID}} #{{.EntityID}}{{end}}{{else}}—{{end}}</td>
|
||||||
|
<td class="small">{{.Summary}}</td>
|
||||||
|
<td class="mono small muted">{{.IP}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{template "pager" .D.Pager}}
|
||||||
|
{{else}}
|
||||||
|
<div class="empty"><div class="empty-mark">票</div><p>没有符合条件的日志。</p></div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page narrow">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>{{if .D.IsNew}}新增套餐{{else}}编辑套餐{{end}}</h1>
|
||||||
|
<p class="muted">按日套餐用起止日期计算剩余天数;按次套餐记录剩余次数。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form method="post" class="form card" data-package-form
|
||||||
|
action="{{if .D.IsNew}}/packages{{else}}/packages/{{.D.Package.ID}}/edit{{end}}">
|
||||||
|
<label class="field"><span>套餐名称 <i>*</i></span>
|
||||||
|
<input name="name" required maxlength="32" value="{{.D.Package.Name}}" placeholder="例如 包月日常 / 赛季深渊 / 10 次副本">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="field">
|
||||||
|
<span>计费方式 <i>*</i></span>
|
||||||
|
<div class="radios">
|
||||||
|
<label><input type="radio" name="unit" value="day"{{if eq .D.Package.Unit "day"}} checked{{end}}>
|
||||||
|
<b>按日</b><small>有开始日与结束日,每天一条待打任务,可打卡</small></label>
|
||||||
|
<label><input type="radio" name="unit" value="count"{{if eq .D.Package.Unit "count"}} checked{{end}}>
|
||||||
|
<b>按次</b><small>记录剩余次数,排期完成时自动扣 1 次,有效期可选</small></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field"><span>默认量 <i>*</i></span>
|
||||||
|
<div class="input-suffix">
|
||||||
|
<input type="number" name="default_amount" min="1" max="10000" value="{{.D.Package.DefaultAmount}}" required>
|
||||||
|
<b data-unit-label>{{if eq .D.Package.Unit "count"}}次{{else}}天{{end}}</b>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>参考价(元,可空)</span>
|
||||||
|
<input type="number" name="price" min="0" step="1" value="{{if .D.Package.HasPrice}}{{money .D.Package.PriceCents}}{{end}}" placeholder="例如 300">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field"><span>日历色签</span>
|
||||||
|
<input type="color" name="color" value="{{.D.Package.Color}}">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>排序(小的靠前)</span>
|
||||||
|
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field"><span>说明</span>
|
||||||
|
<textarea name="note" rows="2" maxlength="200" placeholder="套餐包含什么、怎么个打法">{{.D.Package.Note}}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" type="submit">保存</button>
|
||||||
|
<a class="btn" href="/packages">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>套餐</h1>
|
||||||
|
<p class="muted">新增套餐只需加一行配置,不会影响任何已有客户与订阅</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions"><a class="btn btn-primary" href="/packages/new">+ 新增套餐</a></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .D.List}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th class="col-dot"></th><th>名称</th><th>类型</th><th>默认量</th><th>参考价</th><th>订阅中 / 总数</th><th>状态</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .D.List}}
|
||||||
|
<tr{{if .Archived}} class="archived"{{end}}>
|
||||||
|
<td><span class="dot big" style="background:{{.Color}}"></span></td>
|
||||||
|
<td>
|
||||||
|
<b>{{.Name}}</b>
|
||||||
|
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{.UnitLabel}}</td>
|
||||||
|
<td class="mono">{{.DefaultAmount}}{{if eq .Unit "day"}} 天{{else}} 次{{end}}</td>
|
||||||
|
<td class="mono">{{if .HasPrice}}¥{{money .PriceCents}}{{else}}—{{end}}</td>
|
||||||
|
<td class="mono">{{.ActiveCount}} / {{.SubCount}}</td>
|
||||||
|
<td>{{if .Archived}}<span class="chip chip-closed">已归档</span>{{else}}<span class="chip chip-active">启用中</span>{{end}}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<a class="btn btn-sm" href="/packages/{{.ID}}/edit">编辑</a>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-mark">票</div>
|
||||||
|
<p>还没有套餐。先建「包月日常」这类常用套餐,之后客户开通就一键完成。</p>
|
||||||
|
<a class="btn btn-primary" href="/packages/new">+ 新增套餐</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="scr">
|
||||||
|
<header class="scr-head">
|
||||||
|
<div class="scr-brand"><span class="mark">代</span>代打台<em>控制台</em></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>到期热力</h2>
|
||||||
|
<span>{{.D.MonthTitle}} · 本月 {{.D.Stats.MonthDue}} 条到期</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}}">
|
||||||
|
<span>{{.Day}}</span>
|
||||||
|
{{if .Due}}<b>{{.Due}}</b>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="heat-legend">
|
||||||
|
<span>到期数</span><i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
|
||||||
|
</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>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成</span>
|
||||||
|
</div>
|
||||||
|
{{if .D.TodayList}}
|
||||||
|
<ul class="today-list">
|
||||||
|
{{range .D.TodayList}}
|
||||||
|
<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>服务时长变更流水</span></div>
|
||||||
|
{{if .D.Recent}}
|
||||||
|
<ul class="recent-list">
|
||||||
|
{{range .D.Recent}}
|
||||||
|
<li>
|
||||||
|
<span class="time mono">{{.CreatedAt}}</span>
|
||||||
|
<span class="who"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
|
||||||
|
<span class="pkg">{{.PackageName}}</span>
|
||||||
|
{{if gt .DaysAdded 0}}<em class="plus">+{{.DaysAdded}} 天</em>{{end}}
|
||||||
|
{{if gt .CountAdded 0}}<em class="plus">+{{.CountAdded}} 次</em>{{end}}
|
||||||
|
<span class="op">{{.OperatorName}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="scr-empty">还没有追加记录</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 进入全屏</footer>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page narrow">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>设置</h1>
|
||||||
|
<p class="muted">预警天数、备份、账户与数据导出</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section card">
|
||||||
|
<h2>基础设置</h2>
|
||||||
|
<form method="post" action="/settings" class="form">
|
||||||
|
<div class="grid-2">
|
||||||
|
<label class="field"><span>到期预警(天)</span>
|
||||||
|
<input type="number" name="warn_days" min="0" max="60" value="{{.D.WarnDays}}">
|
||||||
|
</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>
|
||||||
|
<div class="actions"><button class="btn btn-primary" type="submit">保存设置</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section card">
|
||||||
|
<h2>管理员</h2>
|
||||||
|
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table tight">
|
||||||
|
<thead><tr><th>用户名</th><th>显示名称</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{$me := .User}}
|
||||||
|
{{range .D.Users}}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{.Username}}</td>
|
||||||
|
<td>{{.DisplayName}}</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}}
|
||||||
|
<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">
|
||||||
|
<h2>修改我的密码</h2>
|
||||||
|
<form method="post" action="/settings/password" class="form">
|
||||||
|
<div class="grid-3">
|
||||||
|
<label class="field"><span>原密码</span><input type="password" name="old_password" required autocomplete="current-password"></label>
|
||||||
|
<label class="field"><span>新密码(≥6 位)</span><input type="password" name="password" required minlength="6" autocomplete="new-password"></label>
|
||||||
|
<label class="field"><span>再输一次</span><input type="password" name="password2" required minlength="6" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button class="btn btn-primary" type="submit">更新密码</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section card">
|
||||||
|
<h2>数据</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<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.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}}/screen</dd></div>
|
||||||
|
<div><dt>已运行</dt><dd class="mono">{{.D.Uptime}}</dd></div>
|
||||||
|
<div><dt>数据量</dt><dd>{{.D.CustomerCount}} 位客户 · {{.D.PackageCount}} 个套餐 · {{.D.LogCount}} 条日志</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="actions wrap">
|
||||||
|
<form method="post" action="/settings/backup"><button class="btn" type="submit">立即备份</button></form>
|
||||||
|
<a class="btn" href="/export/customers.csv">导出客户 CSV</a>
|
||||||
|
<a class="btn" href="/export/subscriptions.csv">导出订阅 CSV</a>
|
||||||
|
<a class="btn" href="/export/logs.csv">导出日志 CSV</a>
|
||||||
|
</div>
|
||||||
|
{{if .D.Backups}}
|
||||||
|
<details class="backups">
|
||||||
|
<summary>最近备份({{len .D.Backups}} 份)</summary>
|
||||||
|
<ul class="mono small">
|
||||||
|
{{range .D.Backups}}<li>{{.}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="auth">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-brand"><span class="brand-mark">代</span><span>代打台</span></div>
|
||||||
|
<h1>创建第一个管理员</h1>
|
||||||
|
<p class="muted">数据库里还没有账户。设置好账号后即可开始录入套餐与客户。</p>
|
||||||
|
<form method="post" action="/setup" class="form">
|
||||||
|
<label class="field"><span>用户名</span>
|
||||||
|
<input type="text" name="username" required minlength="3" maxlength="32" autocomplete="username" placeholder="建议用拼音或英文名">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>显示名称</span>
|
||||||
|
<input type="text" name="display_name" maxlength="32" placeholder="例如:老王(可留空)">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>密码(至少 6 位)</span>
|
||||||
|
<input type="password" name="password" required minlength="6" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>再输一次密码</span>
|
||||||
|
<input type="password" name="password2" required minlength="6" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">创建并登录</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-foot muted">忘记密码只能手动改数据库,请妥善保管。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>订阅</h1>
|
||||||
|
<p class="muted">共 {{.D.Total}} 条 · 剩余时长各自独立计算</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<a href="/subscriptions?state=active" class="{{if eq .D.State "active"}}on{{end}}">进行中</a>
|
||||||
|
<a href="/subscriptions?state=due7" class="{{if eq .D.State "due7"}}on{{end}}">7 天内到期</a>
|
||||||
|
<a href="/subscriptions?state=due_today" class="{{if eq .D.State "due_today"}}on{{end}}">今天到期</a>
|
||||||
|
<a href="/subscriptions?state=expired" class="{{if eq .D.State "expired"}}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=all" class="{{if eq .D.State "all"}}on{{end}}">全部</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filterbar" method="get" action="/subscriptions">
|
||||||
|
<input type="hidden" name="state" value="{{.D.State}}">
|
||||||
|
<input type="search" name="q" value="{{.D.Q}}" placeholder="尾号 / 姓名 / 套餐" autocomplete="off">
|
||||||
|
<select name="package_id">
|
||||||
|
<option value="0">全部套餐</option>
|
||||||
|
{{range .D.Packages}}
|
||||||
|
<option value="{{.ID}}"{{if eq $.D.PackageID .ID}} selected{{end}}>{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button class="btn" type="submit">筛选</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .D.List}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>客户</th><th>套餐</th><th>状态</th><th>剩余</th><th>时间</th><th>操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .D.List}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="tail" href="/customers/{{.CustomerID}}">{{.CustomerTail}}</a>
|
||||||
|
<a class="name" href="/customers/{{.CustomerID}}">{{.CustomerName}}</a>
|
||||||
|
</td>
|
||||||
|
<td><span class="dot" style="background:{{.PackageColor}}"></span>{{.PackageName}}
|
||||||
|
<span class="muted small">{{.UnitLabel}}</span></td>
|
||||||
|
<td>{{template "state-chip" .}}</td>
|
||||||
|
<td class="mono">{{.RemainText}}{{if .DueText}}<div class="due due-{{.Eval.Level}} small">{{.DueText}}</div>{{end}}</td>
|
||||||
|
<td class="mono small">{{.PeriodText}}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
{{if ne .Status "closed"}}
|
||||||
|
<a class="btn btn-sm" href="/customers/{{.CustomerID}}?extend={{.ID}}#extend">追加</a>
|
||||||
|
{{if eq .Status "active"}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/pause"><button class="btn btn-sm btn-ghost">暂停</button></form>
|
||||||
|
{{else if eq .Status "paused"}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/resume"><button class="btn btn-sm">恢复</button></form>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/subscriptions/{{.ID}}/reopen"><button class="btn btn-sm">重新启用</button></form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{template "pager" .D.Pager}}
|
||||||
|
{{else}}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-mark">票</div>
|
||||||
|
<p>这个筛选下没有订阅。</p>
|
||||||
|
<a class="btn" href="/subscriptions?state=all">看全部</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/domain"
|
||||||
|
"daili/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubView 是订阅的展示视图(已算好状态、文案与进度)。
|
||||||
|
type SubView struct {
|
||||||
|
store.Subscription
|
||||||
|
Eval domain.Eval
|
||||||
|
StateLabel string
|
||||||
|
StateClass string
|
||||||
|
RemainText string
|
||||||
|
RemainShort string
|
||||||
|
PeriodText string
|
||||||
|
DueText string
|
||||||
|
Progress int
|
||||||
|
Checkin *store.Checkin
|
||||||
|
CheckinState string
|
||||||
|
UnitLabel string
|
||||||
|
Today string
|
||||||
|
// CanCheckinToday 表示今天可以直接打卡(订阅在有效状态与区间内)
|
||||||
|
CanCheckinToday bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateLabel(state string) (string, string) {
|
||||||
|
switch state {
|
||||||
|
case domain.StateActive:
|
||||||
|
return "进行中", "active"
|
||||||
|
case domain.StatePending:
|
||||||
|
return "未开始", "pending"
|
||||||
|
case domain.StatePaused:
|
||||||
|
return "暂停中", "paused"
|
||||||
|
case domain.StateExpired:
|
||||||
|
return "已到期", "expired"
|
||||||
|
case domain.StateUsedUp:
|
||||||
|
return "次数用尽", "usedup"
|
||||||
|
case domain.StateClosed:
|
||||||
|
return "已结束", "closed"
|
||||||
|
}
|
||||||
|
return "未知", "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
|
||||||
|
e := s.Eval(today, warnDays)
|
||||||
|
label, class := stateLabel(e.State)
|
||||||
|
v := SubView{
|
||||||
|
Subscription: s,
|
||||||
|
Eval: e,
|
||||||
|
StateLabel: label,
|
||||||
|
StateClass: class,
|
||||||
|
Today: domain.Date(today),
|
||||||
|
}
|
||||||
|
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 次", e.RemainingCount, s.TotalCount)
|
||||||
|
v.RemainShort = fmt.Sprintf("%d 次", e.RemainingCount)
|
||||||
|
v.PeriodText = s.StartDate + " 起"
|
||||||
|
if s.EndDate != "" {
|
||||||
|
v.PeriodText += " · 有效期至 " + s.EndDate
|
||||||
|
} else {
|
||||||
|
v.PeriodText += " · 无有效期"
|
||||||
|
}
|
||||||
|
if s.TotalCount > 0 {
|
||||||
|
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e.Level {
|
||||||
|
case domain.LevelToday:
|
||||||
|
v.DueText = "今天到期"
|
||||||
|
case domain.LevelWarn:
|
||||||
|
switch {
|
||||||
|
case e.DaysLeft == 1:
|
||||||
|
v.DueText = "明天到期"
|
||||||
|
case e.DaysLeft > 1:
|
||||||
|
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
|
||||||
|
case s.Unit == domain.UnitDay:
|
||||||
|
v.DueText = fmt.Sprintf("还剩 %d 天", e.RemainingDays)
|
||||||
|
default:
|
||||||
|
v.DueText = fmt.Sprintf("%d 天后到期", e.DaysLeft)
|
||||||
|
}
|
||||||
|
case domain.LevelExpired:
|
||||||
|
if s.EndDate != "" {
|
||||||
|
d := domain.DayDiff(domain.ParseDateOr(s.EndDate, today), today)
|
||||||
|
if d > 0 {
|
||||||
|
v.DueText = fmt.Sprintf("已过期 %d 天", d)
|
||||||
|
} else {
|
||||||
|
v.DueText = "已到期"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v.DueText = "次数已用尽"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 今天能否打卡:进行中且(按日或有剩余次数)
|
||||||
|
v.CanCheckinToday = e.State == domain.StateActive &&
|
||||||
|
(s.Unit == domain.UnitDay || e.RemainingCount > 0)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampPct(v int) int {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func subViews(subs []store.Subscription, today time.Time, warnDays int) []SubView {
|
||||||
|
out := make([]SubView, 0, len(subs))
|
||||||
|
for _, s := range subs {
|
||||||
|
out = append(out, newSubView(s, today, warnDays))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 追加面板 ----------
|
||||||
|
|
||||||
|
// ExtendPanel 是「追加时长/次数」面板数据。
|
||||||
|
type ExtendPanel struct {
|
||||||
|
Sub store.Subscription
|
||||||
|
View SubView
|
||||||
|
IsDay bool
|
||||||
|
Today string
|
||||||
|
OldEnd string
|
||||||
|
Base string
|
||||||
|
Suggested string
|
||||||
|
SuggestedDays int
|
||||||
|
Back string
|
||||||
|
Months []MiniMonth
|
||||||
|
DefaultAmount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniMonth 是面板内的迷你月历。
|
||||||
|
type MiniMonth struct {
|
||||||
|
Title string
|
||||||
|
Weeks [][]MiniDay
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniDay 是迷你月历的一天。
|
||||||
|
type MiniDay struct {
|
||||||
|
Date string
|
||||||
|
Day int
|
||||||
|
InMonth bool
|
||||||
|
IsOldEnd bool
|
||||||
|
IsToday bool
|
||||||
|
Disabled bool
|
||||||
|
IsWeekend bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExtendPanel(s *store.Subscription, pkg *store.Package, today time.Time, back string, warnDays int) *ExtendPanel {
|
||||||
|
p := &ExtendPanel{
|
||||||
|
Sub: *s,
|
||||||
|
View: newSubView(*s, today, warnDays),
|
||||||
|
IsDay: s.Unit == domain.UnitDay,
|
||||||
|
Today: domain.Date(today),
|
||||||
|
Back: back,
|
||||||
|
DefaultAmount: pkg.DefaultAmount,
|
||||||
|
}
|
||||||
|
if p.DefaultAmount <= 0 {
|
||||||
|
p.DefaultAmount = 30
|
||||||
|
}
|
||||||
|
if s.Unit != domain.UnitDay {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
oldEnd := domain.ParseDateOr(s.EndDate, today)
|
||||||
|
plan := domain.PlanExtend(oldEnd, oldEnd, today)
|
||||||
|
suggested := domain.AddDays(plan.Base, p.DefaultAmount-1)
|
||||||
|
p.OldEnd = domain.Date(oldEnd)
|
||||||
|
p.Base = domain.Date(plan.Base)
|
||||||
|
p.Suggested = domain.Date(suggested)
|
||||||
|
p.SuggestedDays = p.DefaultAmount
|
||||||
|
|
||||||
|
anchor := oldEnd
|
||||||
|
if anchor.Before(today) {
|
||||||
|
anchor = today
|
||||||
|
}
|
||||||
|
sy, sm := anchor.Year(), anchor.Month()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
mm := time.Month(int(sm) + i)
|
||||||
|
y := sy
|
||||||
|
for mm > 12 {
|
||||||
|
mm -= 12
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
p.Months = append(p.Months, buildMiniMonth(y, mm, plan.Base, oldEnd, today))
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMiniMonth(y int, m time.Month, base, oldEnd, today time.Time) MiniMonth {
|
||||||
|
first := domain.MonthStart(y, m)
|
||||||
|
last := domain.MonthEnd(y, m)
|
||||||
|
start := domain.AddDays(first, -domain.WeekdayIndex(first))
|
||||||
|
end := domain.AddDays(last, 7-domain.WeekdayIndex(last)-1)
|
||||||
|
if domain.WeekdayIndex(last) == 6 {
|
||||||
|
end = last
|
||||||
|
}
|
||||||
|
mm := MiniMonth{Title: fmt.Sprintf("%d 年 %d 月", y, int(m))}
|
||||||
|
var week []MiniDay
|
||||||
|
for d := start; !d.After(end); d = domain.AddDays(d, 1) {
|
||||||
|
md := MiniDay{
|
||||||
|
Date: domain.Date(d),
|
||||||
|
Day: d.Day(),
|
||||||
|
InMonth: d.Month() == m,
|
||||||
|
IsOldEnd: domain.Date(d) == domain.Date(oldEnd),
|
||||||
|
IsToday: domain.Date(d) == domain.Date(today),
|
||||||
|
Disabled: d.Before(base),
|
||||||
|
IsWeekend: d.Weekday() == time.Saturday || d.Weekday() == time.Sunday,
|
||||||
|
}
|
||||||
|
week = append(week, md)
|
||||||
|
if len(week) == 7 {
|
||||||
|
mm.Weeks = append(mm.Weeks, week)
|
||||||
|
week = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(week) > 0 {
|
||||||
|
mm.Weeks = append(mm.Weeks, week)
|
||||||
|
}
|
||||||
|
return mm
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 时间轴 ----------
|
||||||
|
|
||||||
|
// Timeline 是客户订阅的时间轴(签带)。
|
||||||
|
type Timeline struct {
|
||||||
|
Start string
|
||||||
|
End string
|
||||||
|
Ticks []Tick
|
||||||
|
Bars []Bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick 是时间轴刻度。
|
||||||
|
type Tick struct {
|
||||||
|
Label string
|
||||||
|
Left float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bar 是一条订阅签带。
|
||||||
|
type Bar struct {
|
||||||
|
SubID int64
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
Left float64
|
||||||
|
Width float64
|
||||||
|
DueLeft float64
|
||||||
|
Label string
|
||||||
|
State string
|
||||||
|
Pauses []BarPause
|
||||||
|
HasDue bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarPause 是签带上的暂停灰段。
|
||||||
|
type BarPause struct {
|
||||||
|
Left float64
|
||||||
|
Width float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTimeline(subs []store.Subscription, pauses map[int64][]store.Pause, today time.Time) *Timeline {
|
||||||
|
if len(subs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
start := today
|
||||||
|
end := today
|
||||||
|
for _, s := range subs {
|
||||||
|
st := domain.ParseDateOr(s.StartDate, today)
|
||||||
|
if st.Before(start) {
|
||||||
|
start = st
|
||||||
|
}
|
||||||
|
en := st.AddDate(0, 0, 30)
|
||||||
|
if s.EndDate != "" {
|
||||||
|
en = domain.ParseDateOr(s.EndDate, en)
|
||||||
|
}
|
||||||
|
if en.After(end) {
|
||||||
|
end = en
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start = domain.AddDays(start, -7)
|
||||||
|
end = domain.AddDays(end, 14)
|
||||||
|
totalDays := domain.DayDiff(start, end) + 1
|
||||||
|
if totalDays <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pos := func(t time.Time) float64 {
|
||||||
|
return float64(domain.DayDiff(start, t)) / float64(totalDays) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
tl := &Timeline{Start: domain.Date(start), End: domain.Date(end)}
|
||||||
|
|
||||||
|
// 月刻度
|
||||||
|
cur := domain.MonthStart(start.Year(), start.Month())
|
||||||
|
if cur.Before(start) {
|
||||||
|
cur = cur.AddDate(0, 1, 0)
|
||||||
|
}
|
||||||
|
for !cur.After(end) {
|
||||||
|
tl.Ticks = append(tl.Ticks, Tick{
|
||||||
|
Label: fmt.Sprintf("%d.%02d", cur.Year(), int(cur.Month())),
|
||||||
|
Left: pos(cur),
|
||||||
|
})
|
||||||
|
cur = cur.AddDate(0, 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range subs {
|
||||||
|
st := domain.ParseDateOr(s.StartDate, today)
|
||||||
|
en := st.AddDate(0, 0, 30)
|
||||||
|
if s.EndDate != "" {
|
||||||
|
en = domain.ParseDateOr(s.EndDate, en)
|
||||||
|
}
|
||||||
|
if en.Before(st) {
|
||||||
|
en = st
|
||||||
|
}
|
||||||
|
b := Bar{
|
||||||
|
SubID: s.ID,
|
||||||
|
Name: s.PackageName,
|
||||||
|
Color: s.PackageColor,
|
||||||
|
Left: pos(st),
|
||||||
|
Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100,
|
||||||
|
Label: domain.Date(st) + " → " + domain.Date(en),
|
||||||
|
State: s.Status,
|
||||||
|
HasDue: s.EndDate != "",
|
||||||
|
}
|
||||||
|
b.DueLeft = pos(en)
|
||||||
|
if b.Width < 0.8 {
|
||||||
|
b.Width = 0.8
|
||||||
|
}
|
||||||
|
for _, p := range pauses[s.ID] {
|
||||||
|
ps := domain.ParseDateOr(p.StartDate, st)
|
||||||
|
var pe time.Time
|
||||||
|
if p.EndDate != "" {
|
||||||
|
pe = domain.ParseDateOr(p.EndDate, today)
|
||||||
|
} else {
|
||||||
|
pe = today
|
||||||
|
}
|
||||||
|
if pe.Before(ps) {
|
||||||
|
pe = ps
|
||||||
|
}
|
||||||
|
if ps.Before(st) {
|
||||||
|
ps = st
|
||||||
|
}
|
||||||
|
if pe.After(en) {
|
||||||
|
pe = en
|
||||||
|
}
|
||||||
|
if pe.Before(ps) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.Pauses = append(b.Pauses, BarPause{
|
||||||
|
Left: pos(ps),
|
||||||
|
Width: float64(domain.DayDiff(ps, pe)+1) / float64(totalDays) * 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tl.Bars = append(tl.Bars, b)
|
||||||
|
}
|
||||||
|
sort.Slice(tl.Bars, func(i, j int) bool { return tl.Bars[i].SubID < tl.Bars[j].SubID })
|
||||||
|
return tl
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 分页 ----------
|
||||||
|
|
||||||
|
// Pager 是分页控件数据。
|
||||||
|
type Pager struct {
|
||||||
|
Page int
|
||||||
|
Pages int
|
||||||
|
Total int
|
||||||
|
Prev string
|
||||||
|
Next string
|
||||||
|
From int
|
||||||
|
To int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPager(r *http.Request, pageNo, perPage, total int) Pager {
|
||||||
|
pages := (total + perPage - 1) / perPage
|
||||||
|
if pages < 1 {
|
||||||
|
pages = 1
|
||||||
|
}
|
||||||
|
if pageNo < 1 {
|
||||||
|
pageNo = 1
|
||||||
|
}
|
||||||
|
if pageNo > pages {
|
||||||
|
pageNo = pages
|
||||||
|
}
|
||||||
|
mk := func(n int) string {
|
||||||
|
q := url.Values{}
|
||||||
|
for k, v := range r.URL.Query() {
|
||||||
|
q[k] = v
|
||||||
|
}
|
||||||
|
q.Set("page", strconv.Itoa(n))
|
||||||
|
return r.URL.Path + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
p := Pager{Page: pageNo, Pages: pages, Total: total}
|
||||||
|
p.From = (pageNo-1)*perPage + 1
|
||||||
|
if total == 0 {
|
||||||
|
p.From = 0
|
||||||
|
}
|
||||||
|
p.To = pageNo * perPage
|
||||||
|
if p.To > total {
|
||||||
|
p.To = total
|
||||||
|
}
|
||||||
|
if pageNo > 1 {
|
||||||
|
p.Prev = mk(pageNo - 1)
|
||||||
|
}
|
||||||
|
if pageNo < pages {
|
||||||
|
p.Next = mk(pageNo + 1)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// 代打管理平台 · 单文件服务
|
||||||
|
//
|
||||||
|
// 双击本程序即可启动:内嵌 SQLite、模板与静态资源,无需安装任何环境。
|
||||||
|
// 部署参数读取同目录的 daili.ini(不存在会自动生成)。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daili/internal/config"
|
||||||
|
"daili/internal/logx"
|
||||||
|
"daili/internal/store"
|
||||||
|
"daili/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "1.1.0"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
|
||||||
|
addrFlag := flag.String("addr", "", "临时覆盖监听地址,例如 127.0.0.1:19000")
|
||||||
|
dbFlag := flag.String("db", "", "临时覆盖数据库路径")
|
||||||
|
keepFlag := flag.Int("backup-keep", 0, "临时覆盖备份保留份数")
|
||||||
|
noBackup := flag.Bool("no-backup", false, "本次运行关闭自动备份")
|
||||||
|
noOpen := flag.Bool("no-open", false, "本次运行不自动打开浏览器")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
flagsSet := map[string]bool{}
|
||||||
|
flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true })
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags)
|
||||||
|
log.SetPrefix("[代打台] ")
|
||||||
|
|
||||||
|
exeDir := executableDir()
|
||||||
|
cfgPath := *cfgFlag
|
||||||
|
if cfgPath == "" {
|
||||||
|
cfgPath = filepath.Join(exeDir, config.FileName)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cfgPath, exeDir)
|
||||||
|
if err != nil {
|
||||||
|
// 配置写错时不要吞掉,直接指向具体行
|
||||||
|
fmt.Fprintf(os.Stderr, "\n[代打台] 配置文件有问题,已停止启动:\n\n%v\n\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令行覆盖(临时调试用,优先于配置文件)
|
||||||
|
if flagsSet["addr"] {
|
||||||
|
if err := applyAddr(cfg, *addrFlag); err != nil {
|
||||||
|
fatal("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flagsSet["db"] {
|
||||||
|
p := *dbFlag
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
p = filepath.Join(exeDir, p)
|
||||||
|
}
|
||||||
|
cfg.DatabasePath = filepath.Clean(p)
|
||||||
|
}
|
||||||
|
if flagsSet["backup-keep"] {
|
||||||
|
cfg.BackupKeep = *keepFlag
|
||||||
|
}
|
||||||
|
if *noBackup {
|
||||||
|
cfg.BackupEnabled = false
|
||||||
|
}
|
||||||
|
if *noOpen {
|
||||||
|
cfg.OpenBrowser = false
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
fatal("命令行参数不合法:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行日志:控制台 + logs/daili-YYYY-MM-DD.log
|
||||||
|
if cfg.LogEnabled {
|
||||||
|
closer, err := logx.Setup(cfg.LogDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("运行日志无法写入 %s:%v(继续只输出到控制台)", cfg.LogDir, err)
|
||||||
|
} else {
|
||||||
|
defer closer.Close()
|
||||||
|
log.Printf("运行日志:%s", filepath.Join(cfg.LogDir, "daili-"+time.Now().Format("2006-01-02")+".log"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时区:留空跟随系统,否则固定(会影响全部“自然日”计算)
|
||||||
|
if cfg.Timezone != "" {
|
||||||
|
loc, err := time.LoadLocation(cfg.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
fatal("时区 %q 无法加载:%v", cfg.Timezone, err)
|
||||||
|
}
|
||||||
|
time.Local = loc
|
||||||
|
log.Printf("时区已固定为 %s(当前时间 %s)", cfg.Timezone, time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Created {
|
||||||
|
log.Printf("未找到配置文件,已生成默认配置:%s(修改后重启生效)", cfg.Path)
|
||||||
|
} else {
|
||||||
|
log.Printf("配置文件:%s", cfg.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
fatal("打开数据库失败:%v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
fatal("初始化数据库结构失败:%v", err)
|
||||||
|
}
|
||||||
|
db.PurgeSessions()
|
||||||
|
if cfg.BackupEnabled {
|
||||||
|
db.SetBackupDir(cfg.BackupDir)
|
||||||
|
}
|
||||||
|
if mode, err := db.JournalMode(); err == nil {
|
||||||
|
log.Printf("数据库已就绪(%s,journal_mode=%s:全部数据都在这一个文件里,复制它即可迁移)", db.Path, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := web.New(db, web.Config{
|
||||||
|
Addr: cfg.Addr(),
|
||||||
|
DBPath: db.Path,
|
||||||
|
BackupKeep: cfg.BackupKeep,
|
||||||
|
BackupDir: cfg.BackupDir,
|
||||||
|
BackupEnabled: cfg.BackupEnabled,
|
||||||
|
ScreenPublic: cfg.ScreenPublic,
|
||||||
|
ConfigPath: cfg.Path,
|
||||||
|
Version: version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatal("初始化服务失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.BackupEnabled {
|
||||||
|
go func() {
|
||||||
|
run := func() {
|
||||||
|
path, err := db.BackupDaily(cfg.BackupKeep)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("自动备份失败:%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if path != "" {
|
||||||
|
log.Printf("已自动备份数据库:%s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", cfg.Addr())
|
||||||
|
if err != nil {
|
||||||
|
fatal("无法监听 %s:%v\n提示:端口被占用时,可修改 %s 里的 listen_port", cfg.Addr(), err, cfg.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpSrv := &http.Server{
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryURL := printBanner(cfg, db.Path)
|
||||||
|
if cfg.OpenBrowser {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
openBrowser(primaryURL)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("HTTP 服务异常退出:%v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if n, _ := db.CountUsers(); n == 0 {
|
||||||
|
log.Printf("首次运行:请在浏览器中打开上面的地址,按提示创建管理员账号")
|
||||||
|
}
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Printf("正在关闭服务…")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("关闭服务时出错:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n[代打台] "+format+"\n\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAddr 解析 -addr(支持 ":19000" 与 "127.0.0.1:19000")。
|
||||||
|
func applyAddr(cfg *config.Config, v string) error {
|
||||||
|
host, port, err := net.SplitHostPort(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("-addr 格式应为 ip:端口 或 :端口,当前是 %q", v)
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
cfg.ListenIP = host
|
||||||
|
}
|
||||||
|
if port != "" {
|
||||||
|
n, err := strconv.Atoi(port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("-addr 的端口必须是数字,当前是 %q", port)
|
||||||
|
}
|
||||||
|
cfg.ListenPort = n
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executableDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
return wd
|
||||||
|
}
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||||
|
exe = resolved
|
||||||
|
}
|
||||||
|
return filepath.Dir(exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printBanner(cfg *config.Config, dbPath string) string {
|
||||||
|
port := strconv.Itoa(cfg.ListenPort)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" ┌───────────────────────────────────────────────┐")
|
||||||
|
fmt.Println(" │ 代 打 台 · 代打管理平台 │")
|
||||||
|
fmt.Println(" └───────────────────────────────────────────────┘")
|
||||||
|
fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path))
|
||||||
|
fmt.Printf(" 数据库 %s\n", dbPath)
|
||||||
|
fmt.Println(" 访问地址:")
|
||||||
|
|
||||||
|
primary := ""
|
||||||
|
switch {
|
||||||
|
case cfg.LoopbackOnly():
|
||||||
|
primary = "http://127.0.0.1:" + port
|
||||||
|
fmt.Printf(" 本机 %s\n", primary)
|
||||||
|
fmt.Println(" 仅本机可访问(listen_ip = 127.0.0.1)")
|
||||||
|
case cfg.PublicBind():
|
||||||
|
primary = "http://127.0.0.1:" + port
|
||||||
|
fmt.Printf(" 本机 %s\n", primary)
|
||||||
|
for _, ip := range web.LocalAddrs() {
|
||||||
|
fmt.Printf(" 局域网 http://%s:%s\n", ip, port)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
primary = "http://" + net.JoinHostPort(cfg.ListenIP, port)
|
||||||
|
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()
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(url string) {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||||
|
case "darwin":
|
||||||
|
cmd = exec.Command("open", url)
|
||||||
|
default:
|
||||||
|
cmd = exec.Command("xdg-open", url)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("自动打开浏览器失败(可手动访问 %s):%v", url, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
# 冒烟测试执行脚本(HTTP 层逐条断言)
|
||||||
|
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\acceptance.ps1 -Base http://127.0.0.1:18090 -Db <测试库路径>
|
||||||
|
param(
|
||||||
|
[string]$Base = 'http://127.0.0.1:18090',
|
||||||
|
[string]$Db = 'F:\hupeE的代肝平台\testrun\daili.db',
|
||||||
|
[string]$Python = 'E:\Miniconda3\python.exe',
|
||||||
|
[string]$Tools = 'F:\hupeE的代肝平台\scripts\dbtool.py',
|
||||||
|
[string]$LogFile = 'F:\hupeE的代肝平台\testrun\acceptance-result.txt'
|
||||||
|
)
|
||||||
|
|
||||||
|
try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
$env:PYTHONIOENCODING = 'utf-8'
|
||||||
|
$script:results = @()
|
||||||
|
$script:fail = 0
|
||||||
|
$T = (Get-Date).Date
|
||||||
|
$today = $T.ToString('yyyy-MM-dd')
|
||||||
|
|
||||||
|
function D([int]$n) { $T.AddDays($n).ToString('yyyy-MM-dd') }
|
||||||
|
|
||||||
|
function New-Sess {
|
||||||
|
$handler = New-Object System.Net.Http.HttpClientHandler
|
||||||
|
$handler.AllowAutoRedirect = $true
|
||||||
|
$handler.UseCookies = $true
|
||||||
|
$handler.CookieContainer = New-Object System.Net.CookieContainer
|
||||||
|
$client = New-Object System.Net.Http.HttpClient($handler)
|
||||||
|
$client.Timeout = [TimeSpan]::FromSeconds(30)
|
||||||
|
return $client
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send {
|
||||||
|
param($Sess, [string]$Method, [string]$Path, $Body = $null, $Headers = $null)
|
||||||
|
$req = New-Object System.Net.Http.HttpRequestMessage
|
||||||
|
$req.RequestUri = [Uri]("$Base$Path")
|
||||||
|
if ($Method -eq 'POST') { $req.Method = [System.Net.Http.HttpMethod]::Post }
|
||||||
|
else { $req.Method = [System.Net.Http.HttpMethod]::Get }
|
||||||
|
if ($null -ne $Body) {
|
||||||
|
$pairs = New-Object 'System.Collections.Generic.List[System.Collections.Generic.KeyValuePair[string,string]]'
|
||||||
|
foreach ($k in $Body.Keys) {
|
||||||
|
$kv = New-Object 'System.Collections.Generic.KeyValuePair[string,string]'($k, [string]$Body[$k])
|
||||||
|
$pairs.Add($kv)
|
||||||
|
}
|
||||||
|
$req.Content = [System.Net.Http.FormUrlEncodedContent]::new([System.Collections.Generic.IEnumerable[System.Collections.Generic.KeyValuePair[string,string]]]$pairs)
|
||||||
|
}
|
||||||
|
if ($null -ne $Headers) {
|
||||||
|
foreach ($k in $Headers.Keys) { [void]$req.Headers.TryAddWithoutValidation($k, [string]$Headers[$k]) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$resp = $Sess.SendAsync($req).Result
|
||||||
|
$text = $resp.Content.ReadAsStringAsync().Result
|
||||||
|
$final = $resp.RequestMessage.RequestUri.AbsolutePath
|
||||||
|
$resp.Dispose()
|
||||||
|
return @{ Status = [int]$resp.StatusCode; Content = $text; Path = $final }
|
||||||
|
} catch {
|
||||||
|
return @{ Status = 0; Content = ''; Path = ''; Err = $_.Exception.Message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Check {
|
||||||
|
param([string]$Id, [string]$Name, $Cond, [string]$Detail = '')
|
||||||
|
$ok = ($Cond -eq $true)
|
||||||
|
if ($ok) {
|
||||||
|
Write-Host (" [PASS] {0} {1}" -f $Id, $Name) -ForegroundColor Green
|
||||||
|
$script:results += [pscustomobject]@{ ID = $Id; Name = $Name; Result = 'PASS'; Detail = $Detail }
|
||||||
|
} else {
|
||||||
|
Write-Host (" [FAIL] {0} {1} {2}" -f $Id, $Name, $Detail) -ForegroundColor Red
|
||||||
|
$script:results += [pscustomobject]@{ ID = $Id; Name = $Name; Result = 'FAIL'; Detail = $Detail }
|
||||||
|
$script:fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Has { param($Text, [string]$Pattern) return ([string]$Text) -match $Pattern }
|
||||||
|
function Flash { param($Text) $m = [regex]::Match([string]$Text, '<div class="flash[^>]*>([^<]*)</div>'); return $m.Groups[1].Value }
|
||||||
|
function SubIDs { param($Text) return @([regex]::Matches([string]$Text, 'id="sub-(\d+)"') | ForEach-Object { [int]$_.Groups[1].Value }) }
|
||||||
|
function RowCount { param($Text) return [regex]::Matches([string]$Text, 'class="tail"').Count }
|
||||||
|
function Dbtool { param([string[]]$ToolArgs) return (& $Python $Tools @ToolArgs) -join ' ' }
|
||||||
|
|
||||||
|
$admin = New-Sess
|
||||||
|
$anon = New-Sess
|
||||||
|
|
||||||
|
Write-Host "`n=== 1. 启动与部署(S) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $anon 'GET' '/healthz'
|
||||||
|
Check 'S-02' '健康检查 /healthz 返回 ok' ($r.Status -eq 200 -and $r.Content -match 'ok')
|
||||||
|
|
||||||
|
$r = Send $anon 'GET' '/'
|
||||||
|
Check 'S-03' '首访跳 /setup' ($r.Path -eq '/setup' -and (Has $r.Content '创建第一个管理员')) "path=$($r.Path)"
|
||||||
|
|
||||||
|
$okStatic = $true; $detail = @()
|
||||||
|
foreach ($p in '/static/app.css', '/static/screen.css', '/static/app.js', '/static/screen.js', '/static/favicon.svg') {
|
||||||
|
$r = Send $anon 'GET' $p
|
||||||
|
$okStatic = $okStatic -and ($r.Status -eq 200) -and ($r.Content.Length -gt 100)
|
||||||
|
$detail += "$p=$($r.Status)/$($r.Content.Length)"
|
||||||
|
}
|
||||||
|
Check 'S-04' '静态资源可访问' $okStatic ($detail -join ' ')
|
||||||
|
|
||||||
|
Write-Host "`n=== 2. 账户与权限(A) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/setup' @{ username = 'ab'; display_name = 'x'; password = 'daili123'; password2 = 'daili123' }
|
||||||
|
Check 'A-01' '用户名过短被拒' ($r.Status -eq 400 -and (Has $r.Content '用户名至少 3 个字符')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = '123'; password2 = '123' }
|
||||||
|
Check 'A-02' '密码过短被拒' ($r.Status -eq 400 -and (Has $r.Content '密码至少 6 位')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili999' }
|
||||||
|
Check 'A-03' '两次密码不一致被拒' ($r.Status -eq 400 -and (Has $r.Content '两次输入的密码不一致')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili123' }
|
||||||
|
Check 'A-04' '初始化管理员成功并自动登录' ($r.Status -eq 200 -and $r.Path -eq '/packages') "path=$($r.Path)"
|
||||||
|
|
||||||
|
Write-Host "`n=== 3. 套餐(P) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' '/packages'
|
||||||
|
Check 'P-01' '套餐空态提示' ((Has $r.Content '还没有套餐'))
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/packages' @{ name = '包月日常'; unit = 'day'; default_amount = '30'; price = '300'; color = '#2F5D50'; note = '每天日常 + 周常'; sort_order = '1' }
|
||||||
|
Check 'P-02' '新增按日套餐' ((Has $r.Content '套餐已创建')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/packages' @{ name = '深渊十次'; unit = 'count'; default_amount = '10'; price = '180'; color = '#6B4E9B'; note = '按次结算'; sort_order = '2' }
|
||||||
|
Check 'P-03' '新增按次套餐' ((Has $r.Content '套餐已创建')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/packages' @{ name = ''; unit = 'day'; default_amount = '0'; color = ''; sort_order = '0' }
|
||||||
|
Check 'P-04' '套餐必填校验' ($r.Status -eq 400 -and (Has $r.Content '套餐名称不能为空')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
Write-Host "`n=== 4. 登录与会话(A) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/logout'
|
||||||
|
$r = Send $admin 'GET' '/calendar'
|
||||||
|
Check 'A-05' '登出后受保护页面跳登录' ($r.Path -like '/login*')
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/login' @{ username = 'admin'; password = 'wrongpass'; next = '/calendar' }
|
||||||
|
Check 'A-06' '错误口令被拒' ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/login' @{ username = 'admin'; password = 'daili123'; next = '/calendar' }
|
||||||
|
Check 'A-04b' '重新登录成功' ($r.Path -eq '/calendar' -and (Has $r.Content '欢迎回来')) "path=$($r.Path)"
|
||||||
|
|
||||||
|
$screenPublic = ((Send $admin 'GET' '/settings').Content -match '(?s)大屏</dt>.*?免登录')
|
||||||
|
$r = Send $anon 'GET' '/customers'
|
||||||
|
$p1 = $r.Path
|
||||||
|
$r = Send $anon 'GET' '/screen'
|
||||||
|
$p2 = $r.Path
|
||||||
|
$screenOk = if ($screenPublic) { $p2 -eq '/screen' } else { $p2 -like '/login*' }
|
||||||
|
Check 'A-07' '未登录拦截后台页(大屏按配置)' (($p1 -like '/login*') -and $screenOk) "customers=$p1 screen=$p2 public=$screenPublic"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/packages' @{ name = '恶意套餐'; unit = 'day'; default_amount = '1'; color = '#2F5D50' } @{ Origin = 'http://evil.example' }
|
||||||
|
Check 'A-12' '跨站 POST 被拒绝' ($r.Status -eq 403) "status=$($r.Status)"
|
||||||
|
|
||||||
|
Write-Host "`n=== 5. 客户(C) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '张三'; game_name = '王者荣耀'; game_server = '微信区'; contact = 'wx:1001'; tags = '老客户,包月 老客户'; source = '抖音'; note = '主用例' }
|
||||||
|
$custPath = $r.Path
|
||||||
|
$custID = ($custPath -split '/')[-1]
|
||||||
|
Check 'C-01' '新建客户成功' ($custPath -like '/customers/*') "path=$custPath"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '张三' }
|
||||||
|
Check 'C-02' '尾号+姓名重复被拒' ($r.Status -eq 400 -and (Has $r.Content '已存在')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '李四'; game_name = '王者荣耀' }
|
||||||
|
$custLiID = ($r.Path -split '/')[-1]
|
||||||
|
Check 'C-03' '同尾号不同姓名允许' ($r.Path -like '/customers/*') "path=$($r.Path)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = ''; name = '无尾号' }
|
||||||
|
Check 'C-04' '尾号为空被拒' ($r.Status -eq 400 -and (Has $r.Content '手机尾号不能为空')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = '123456789'; name = '超长尾号' }
|
||||||
|
Check 'C-05' '尾号超长被拒' ($r.Status -eq 400 -and (Has $r.Content '最长 8 位')) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' '/customers?q=1001'
|
||||||
|
Check 'C-06' '尾号搜索命中两人' ((Has $r.Content '张三') -and (Has $r.Content '李四') -and (RowCount $r.Content) -eq 2) "rows=$(RowCount $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' '/customers?q=%E7%8E%8B%E8%80%85'
|
||||||
|
Check 'C-07a' '按游戏名搜索' ((RowCount $r.Content) -ge 2) "rows=$(RowCount $r.Content)"
|
||||||
|
$r = Send $admin 'GET' '/customers?q=wx%3A1001'
|
||||||
|
Check 'C-07b' '按联系方式搜索' ((Has $r.Content '张三')) "rows=$(RowCount $r.Content)"
|
||||||
|
$r = Send $admin 'GET' '/customers?q=%E6%9D%8E%E5%9B%9B'
|
||||||
|
Check 'C-07c' '按姓名搜索' ((RowCount $r.Content) -eq 1) "rows=$(RowCount $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
$tagVal = Dbtool @('scalar', $Db, "SELECT tags FROM customers WHERE id=$custID")
|
||||||
|
Check 'C-08' '标签归一化(逗号/空格/去重)' ($tagVal.Trim() -eq '老客户,包月') "tags=$tagVal"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/customers/$custID/edit" @{ tail = '1001'; name = '张三'; game_name = '王者荣耀'; game_server = '安卓微信区'; contact = 'wx:1001'; tags = '老客户,包月'; source = '抖音'; note = '已编辑' }
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
Check 'C-09' '编辑客户生效' ((Has $r.Content '安卓微信区') -and (Has $r.Content '已编辑')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
# 批量客户:用于分页
|
||||||
|
for ($i = 1; $i -le 22; $i++) {
|
||||||
|
$tail = 'P' + $i.ToString('000')
|
||||||
|
Send $admin 'POST' '/customers' @{ tail = $tail; name = "分页客户$i" } | Out-Null
|
||||||
|
}
|
||||||
|
$r = Send $admin 'GET' '/customers'
|
||||||
|
$total = [int][regex]::Match($r.Content, '共 (\d+) 位').Groups[1].Value
|
||||||
|
$r2 = Send $admin 'GET' '/customers?page=2'
|
||||||
|
$expect = [Math]::Min(20, $total - 20)
|
||||||
|
Check 'C-10' '客户分页正常' (($r2.Status -eq 200) -and (RowCount $r2.Content) -eq $expect -and $total -ge 24) "total=$total page2=$(RowCount $r2.Content) expect=$expect"
|
||||||
|
|
||||||
|
Write-Host "`n=== 6. 订阅(U) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30'; note = '首单' }
|
||||||
|
Check 'U-01' '开通按日订阅(结束日=T+29)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
$p1ID = (SubIDs $r.Content)[0]
|
||||||
|
$detail = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'U-01b' '按日剩余 30 天' ((Has $detail '剩 30 天')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '2'; start_date = $today; total_count = '10'; end_date = (D 10) }
|
||||||
|
Check 'U-02' '开通按次订阅(10 次 / 有效期 T+10)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
$p2ID = (SubIDs $r.Content)[1]
|
||||||
|
Check 'U-02b' '按次剩余 10/10 次' ((Has $r.Content '剩 10 / 10 次')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
|
||||||
|
Check 'U-03' '重复订阅被拦截' ((Has $r.Content '已订阅此套餐')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
# P-05:新增套餐 D(id=3),验证既有订阅不受影响
|
||||||
|
$before = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
$r = Send $admin 'POST' '/packages' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3' }
|
||||||
|
Check 'P-05' '新增套餐 D 成功' ((Has $r.Content '套餐已创建')) ""
|
||||||
|
$after = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'P-05b' '新增套餐后既有订阅剩余量不变' ((Has $after '剩 30 天') -and (Has $after '剩 10 / 10 次')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '3'; start_date = $today; total_count = '5' }
|
||||||
|
Check 'U-04' '开通第三个套餐(无有效期按次)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
$ids = SubIDs $r.Content
|
||||||
|
$p3ID = $ids[2]
|
||||||
|
Check 'U-04b' '三条订阅并存' ($ids.Count -eq 3) "count=$($ids.Count)"
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' '/subscriptions?state=active'
|
||||||
|
Check 'U-05a' '筛选:进行中' ((Has $r.Content '张三') -and (Has $r.Content '进行中')) ""
|
||||||
|
$r = Send $admin 'GET' '/subscriptions?state=due7'
|
||||||
|
Check 'U-05b' '筛选:7 天内到期' ($r.Status -eq 200) "status=$($r.Status)"
|
||||||
|
$r = Send $admin 'GET' '/subscriptions?state=closed'
|
||||||
|
Check 'U-05c' '筛选:已结束(应为空)' ($r.Status -eq 200 -and (Has $r.Content '没有订阅')) "flash=$(Has $r.Content '没有订阅')"
|
||||||
|
$r = Send $admin 'GET' '/subscriptions?package_id=2'
|
||||||
|
Check 'U-06' '按套餐筛选' ((Has $r.Content '张三') -and -not (Has $r.Content '阿豪')) ""
|
||||||
|
|
||||||
|
# 过期与未开始
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = '9001'; name = '阿豪' }
|
||||||
|
$custHaoID = ($r.Path -split '/')[-1]
|
||||||
|
Send $admin 'POST' "/customers/$custHaoID/subscriptions" @{ package_id = '1'; start_date = (D -40); days = '30' } | Out-Null
|
||||||
|
$r = Send $admin 'GET' "/customers/$custHaoID"
|
||||||
|
$haoSubID = (SubIDs $r.Content)[0]
|
||||||
|
Check 'U-07a' '已过期订阅显示「已到期」' ((Has $r.Content '已到期')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/customers/$custLiID/subscriptions" @{ package_id = '1'; start_date = (D 5); days = '30' } | Out-Null
|
||||||
|
$r = Send $admin 'GET' "/customers/$custLiID"
|
||||||
|
$liSubID = (SubIDs $r.Content)[0]
|
||||||
|
Check 'U-07b' '未开始订阅显示「未开始」' ((Has $r.Content '未开始')) ""
|
||||||
|
|
||||||
|
Write-Host "`n=== 7. 套餐编辑与归档(P) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/packages/3/edit' @{ name = '副本五次(改)'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; sort_order = '3' }
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
Check 'P-06' '编辑套餐后订阅同步显示新名称' ((Has $r.Content '副本五次\(改\)')) "flash=$(Flash $r.Content)"
|
||||||
|
Send $admin 'POST' '/packages/3/edit' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; sort_order = '3' } | Out-Null
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/packages/2/archive'
|
||||||
|
Check 'P-07a' '归档套餐' ((Has $r.Content '已归档')) "flash=$(Flash $r.Content)"
|
||||||
|
$r = Send $admin 'POST' "/customers/$custLiID/subscriptions" @{ package_id = '2'; start_date = $today; total_count = '10' }
|
||||||
|
Check 'P-07b' '归档后不能新建该套餐订阅' ((Has $r.Content '已归档,不能新建')) "flash=$(Flash $r.Content)"
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
Check 'P-08' '归档后已有订阅照常显示' ((Has $r.Content '深渊十次')) ""
|
||||||
|
Send $admin 'POST' '/packages/2/archive' | Out-Null
|
||||||
|
|
||||||
|
Write-Host "`n=== 8. 追加时长(E) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 39); note = '未到期续费'; back = "/customers/$custID" }
|
||||||
|
Check 'E-01' '未到期追加:起点=T+30,追加 10 天' ($r -ne $null -and (Has $r.Content '已追加 10 天')) "flash=$(Flash $r.Content)"
|
||||||
|
$detail = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'E-01b' '追加后剩余 40 天' ((Has $detail '剩 40 天')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$haoSubID/extend" @{ new_end_date = (D 29); note = '过期续费'; back = "/customers/$custHaoID" }
|
||||||
|
Check 'E-02' '已到期追加:从今天起算 30 天' ((Has $r.Content '已追加 30 天')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 10); back = "/customers/$custID" }
|
||||||
|
$d2 = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'E-03' '早于计算起点被拒且数据不变' ((Has $r.Content '追加失败') -and (Has $r.Content '早于计算起点') -and (Has $d2 '剩 40 天')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
Check 'E-04' '追加留痕(起点/旧→新/天数/备注/操作人)' ((Has $detail '未到期续费') -and (Has $detail '追加天数') -and (Has $detail (D 30)) -and (Has $detail '老板')) ""
|
||||||
|
Check 'E-05' '新建订阅有初始「开通」记录' ((Has $detail '开通') -and (Has $detail '\+30 天')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p1ID/pause" @{ back = "/customers/$custID" }
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 49); note = '冻结期续费'; back = "/customers/$custID" }
|
||||||
|
$d3 = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'E-06' '暂停中追加后回到进行中' ((Has $r.Content '已追加 10 天') -and -not (Has $d3 '暂停中')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$liSubID/close"
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$liSubID/extend" @{ new_end_date = (D 40); back = "/customers/$custLiID" }
|
||||||
|
Check 'E-07' '已结束订阅不可追加' ((Has $r.Content '请先「重新启用」')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; note = '加购 5 次'; back = "/customers/$custID" }
|
||||||
|
$d4 = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'E-08' '按次追加次数' ((Has $r.Content '已追加 5 次') -and (Has $d4 '剩 15 / 15 次')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; new_end_opt = (D 30); note = '加次并延期'; back = "/customers/$custID" }
|
||||||
|
$d5 = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'E-09' '按次追加可同时改有效期' ((Has $d5 ('有效期至 ' + (D 30)))) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' '/logs?q=%E8%BF%BD%E5%8A%A0'
|
||||||
|
Check 'E-10' '操作日志可查追加记录' ((Has $r.Content '追加时长') -or (Has $r.Content '追加次数')) ""
|
||||||
|
|
||||||
|
Write-Host "`n=== 9. 暂停 / 恢复(G) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custHaoID").Content
|
||||||
|
Check 'G-01' '暂停后状态为暂停中' ((Has $r.Content '已暂停') -and (Has $d '暂停中')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
Dbtool @('backdate-pause', $Db, "$haoSubID", '5') | Out-Null
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$haoSubID/resume" @{ back = "/customers/$custHaoID" }
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custHaoID").Content
|
||||||
|
Check 'G-02' '恢复后结束日顺延 5 天' ((Has $r.Content '顺延 5 天')) "flash=$(Flash $r.Content)"
|
||||||
|
Check 'G-03' '暂停期间的 5 天被补回(剩余 35 天)' ((Has $d '剩 35 天')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
|
||||||
|
Check 'G-04' '重复暂停被拒' ((Has $r.Content '已处于暂停状态')) "flash=$(Flash $r.Content)"
|
||||||
|
Send $admin 'POST' "/subscriptions/$haoSubID/resume" @{ back = "/customers/$custHaoID" } | Out-Null
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$liSubID/pause" @{ back = "/customers/$custLiID" }
|
||||||
|
Check 'G-05' '已结束订阅不可暂停' ((Has $r.Content '无法暂停')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
# 专用客户:验证暂停后不出现在日历
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = '7001'; name = '单测' }
|
||||||
|
$custCalID = ($r.Path -split '/')[-1]
|
||||||
|
Send $admin 'POST' "/customers/$custCalID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' } | Out-Null
|
||||||
|
$r = Send $admin 'GET' "/customers/$custCalID"
|
||||||
|
$calSubID = (SubIDs $r.Content)[0]
|
||||||
|
$before = (Send $admin 'GET' "/calendar?d=$today").Content
|
||||||
|
Send $admin 'POST' "/subscriptions/$calSubID/pause" @{ back = "/customers/$custCalID" } | Out-Null
|
||||||
|
$after = (Send $admin 'GET' "/calendar?d=$today").Content
|
||||||
|
$link = "/subscriptions/$calSubID/checkin"
|
||||||
|
Check 'G-06' '暂停后不出现在日历待打清单' ((Has $before $link) -and (-not (Has $after $link))) "before=$(Has $before $link) after=$(Has $after $link)"
|
||||||
|
Send $admin 'POST' "/subscriptions/$calSubID/resume" @{ back = "/customers/$custCalID" } | Out-Null
|
||||||
|
|
||||||
|
Write-Host "`n=== 10. 打卡(K) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$calSubID/checkin" @{ date = $today; status = 'done'; note = '今日已打'; back = "/calendar?d=$today" }
|
||||||
|
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
|
||||||
|
Check 'K-01' '按日打卡生成记录并标记完成' ((Has $r.Content '已记录') -and (Has $cal '已完成') -and (-not (Has $cal ("/subscriptions/$calSubID/checkin")))) "flash=$(Flash $r.Content)"
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custCalID").Content
|
||||||
|
Check 'K-02' '按日打卡不扣天数' ((Has $d '剩 30 天')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'K-03' '按次打卡扣 1 次' ((Has $d '剩 19 / 20 次')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'K-04' '同日重复打卡不重复扣次' ((Has $d '剩 19 / 20 次')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'skipped'; back = "/customers/$custID" }
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'K-05' '改为跳过返还 1 次' ((Has $d '剩 20 / 20 次')) ""
|
||||||
|
|
||||||
|
Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" } | Out-Null
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
$ciID = (Dbtool @('scalar', $Db, "SELECT id FROM checkins WHERE sub_id=$p2ID AND service_date='$today'")).Trim()
|
||||||
|
$r = Send $admin 'POST' "/checkins/$ciID/delete" @{ back = "/customers/$custID" }
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'K-06' '撤销打卡返还次数' ((Has $r.Content '已撤销') -and (Has $d '剩 20 / 20 次')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=total_count WHERE id=$p2ID") | Out-Null
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
|
Check 'K-07' '次数用尽时打卡被拒' ((Has $r.Content '剩余次数不足')) "flash=$(Flash $r.Content)"
|
||||||
|
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=0 WHERE id=$p2ID") | Out-Null
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$liSubID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custLiID" }
|
||||||
|
Check 'K-08' '已结束订阅不可打卡' ((Has $r.Content '订阅已结束,无法打卡')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
|
Check 'K-11' '无有效期订阅卡片有「今日打卡」按钮' ((Has $r.Content "/subscriptions/$p3ID/checkin") -and (Has $r.Content '今日打卡')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' "/subscriptions/$p3ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
|
Check 'K-09' '无有效期按次订阅可打卡扣次' ((Has $d '剩 4 / 5 次')) "flash=$(Flash $r.Content)"
|
||||||
|
Check 'K-10' '打卡历史含日期/备注/操作人' ((Has $d '今日已打') -or (Has $d '完成')) ""
|
||||||
|
|
||||||
|
Write-Host "`n=== 11. 日历(L) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$cal = (Send $admin 'GET' '/calendar').Content
|
||||||
|
Check 'L-01' '默认当月且今天选中' ((Has $cal '今天</span>') -and (Has $cal ($T.ToString('M 月 d 日')))) ""
|
||||||
|
$r = Send $admin 'GET' '/calendar?y=2026&m=12'
|
||||||
|
Check 'L-02a' '月份切换到 2026-12' ((Has $r.Content '2026 年 12 月')) ""
|
||||||
|
$r = Send $admin 'GET' '/calendar?y=2027&m=1'
|
||||||
|
Check 'L-02b' '跨年切换到 2027-01' ((Has $r.Content '2027 年 1 月')) ""
|
||||||
|
$r = Send $admin 'GET' '/calendar?y=2020&m=1'
|
||||||
|
Check 'L-03' '「回到今天」入口存在' ((Has $r.Content '回到今天')) ""
|
||||||
|
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
|
||||||
|
Check 'L-04' '格子显示色签与负荷' ((Has $cal '单测') -and (Has $cal '包月') -and (Has $cal 'heat-')) ""
|
||||||
|
Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待打')) ""
|
||||||
|
$r = Send $admin 'GET' "/calendar?d=$(D -1)"
|
||||||
|
Check 'L-06' '过去日期标记已过去' ((Has $r.Content '已过去')) ""
|
||||||
|
Check 'L-07' '近 7 天到期区块' ((Has $cal '近 7 天到期')) ""
|
||||||
|
Check 'L-08' '无有效期按次订阅不进日历' (-not (Has $cal ("/subscriptions/$p3ID/checkin"))) ""
|
||||||
|
$css = (Send $admin 'GET' '/static/app.css').Content
|
||||||
|
Check 'L-09' '移动端样式规则存在' ((Has $css '@media \(max-width: 560px\)') -and (Has $cal 'viewport')) ""
|
||||||
|
|
||||||
|
Write-Host "`n=== 12. 大屏(D) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $anon 'GET' '/screen'
|
||||||
|
$d1Ok = if ($screenPublic) { $r.Status -eq 200 -and $r.Path -eq '/screen' } else { $r.Path -like '/login*' }
|
||||||
|
Check 'D-01' '大屏访问符合 screen_public 配置' $d1Ok "path=$($r.Path) public=$screenPublic"
|
||||||
|
|
||||||
|
$screen = (Send $admin 'GET' '/screen').Content
|
||||||
|
$customers = (Send $admin 'GET' '/customers').Content
|
||||||
|
$custN = [regex]::Match($customers, '共 (\d+) 位').Groups[1].Value
|
||||||
|
$dueToday = [regex]::Match((Send $admin 'GET' '/subscriptions?state=due_today').Content, '共 (\d+) 条').Groups[1].Value
|
||||||
|
$due7 = [regex]::Match((Send $admin 'GET' '/subscriptions?state=due7').Content, '共 (\d+) 条').Groups[1].Value
|
||||||
|
$kpiCust = [regex]::Match($screen, '(?s)客户</span>\s*<b>(\d+)</b>').Groups[1].Value
|
||||||
|
$kpiDue = [regex]::Match($screen, '(?s)今日到期</span>\s*<b>(\d+)</b>').Groups[1].Value
|
||||||
|
$kpi7 = [regex]::Match($screen, '(?s)7 天内到期</span>\s*<b>(\d+)</b>').Groups[1].Value
|
||||||
|
Check 'D-02' '四张 KPI 与后台一致' (($kpiCust -eq $custN) -and ($kpiDue -eq $dueToday) -and ($kpi7 -eq $due7)) "cust=$kpiCust/$custN due=$kpiDue/$dueToday 7=$kpi7/$due7"
|
||||||
|
Check 'D-03' '热力月历渲染' ((Has $screen '到期热力') -and (Has $screen 'heat-cell')) ""
|
||||||
|
$topN = [regex]::Matches($screen, 'class="top-item').Count
|
||||||
|
Check 'D-04' 'TOP 榜不超过 10 条' ((Has $screen '即将到期 TOP 10') -and ($topN -ge 1) -and ($topN -le 10)) "count=$topN"
|
||||||
|
$todayTasks = [regex]::Match($cal, '(\d+) 待打').Groups[1].Value
|
||||||
|
$screenTasks = [regex]::Match($screen, '<span>(\d+) / (\d+) 已完成</span>').Groups[2].Value
|
||||||
|
Check 'D-05' '今日待打数与日历一致' ($todayTasks -eq $screenTasks -and $todayTasks -ne '') "calendar=$todayTasks screen=$screenTasks"
|
||||||
|
Check 'D-06' '最近追加流水渲染' ((Has $screen '最近追加')) ""
|
||||||
|
Check 'D-07' '自动刷新秒数取自设置' ((Has $screen '<meta http-equiv="refresh" content="30">')) ""
|
||||||
|
|
||||||
|
Write-Host "`n=== 13. 日志 / 导出 / 备份(R) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' '/logs'
|
||||||
|
Check 'R-01' '日志列表倒序分页' ((Has $r.Content '共 ') -and (Has $r.Content '操作人')) ""
|
||||||
|
$r = Send $admin 'GET' '/logs?q=%E6%89%93%E5%8D%A1'
|
||||||
|
Check 'R-02' '日志搜索命中' ((Has $r.Content '打卡')) ""
|
||||||
|
$r = Send $admin 'GET' '/logs'
|
||||||
|
Check 'R-03' '关键动作均入库' ((Has $r.Content '追加时长') -and (Has $r.Content '暂停订阅') -and (Has $r.Content '打卡') -and (Has $r.Content '新建客户')) ""
|
||||||
|
|
||||||
|
$respCsv = $admin.GetAsync("$Base/export/customers.csv").Result
|
||||||
|
$bytes = $respCsv.Content.ReadAsByteArrayAsync().Result
|
||||||
|
$csv = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||||
|
$bom = ($bytes.Length -gt 2) -and ($bytes[0] -eq 0xEF) -and ($bytes[1] -eq 0xBB) -and ($bytes[2] -eq 0xBF)
|
||||||
|
$lines = ($csv -split "`n").Count
|
||||||
|
$custNow = [int][regex]::Match((Send $admin 'GET' '/customers').Content, '共 (\d+) 位').Groups[1].Value
|
||||||
|
Check 'R-04' '客户 CSV:BOM + 表头 + 行数匹配' ($bom -and (Has $csv '手机尾号') -and ($lines -ge ($custNow + 1))) "bom=$bom lines=$lines customers=$custNow"
|
||||||
|
$csv2 = (Send $admin 'GET' '/export/subscriptions.csv').Content
|
||||||
|
Check 'R-05' '订阅 CSV 含剩余/类型/状态' ((Has $csv2 '剩余') -and (Has $csv2 '类型') -and (Has $csv2 '原文状态')) ""
|
||||||
|
$csv3 = (Send $admin 'GET' '/export/logs.csv').Content
|
||||||
|
Check 'R-06' '日志 CSV 含操作人/动作' ((Has $csv3 '操作人') -and (Has $csv3 '动作')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/settings/backup'
|
||||||
|
Check 'R-07' '手动备份生成文件' ((Has $r.Content '已备份')) "flash=$(Flash $r.Content)"
|
||||||
|
$verify = (& $Python 'F:\hupeE的代肝平台\scripts\verify-backup.py' 'F:\hupeE的代肝平台\testrun\backup') -join ' '
|
||||||
|
Check 'R-07b' '备份文件可被标准 SQLite 打开且有数据' (($verify -match 'OK') -and ($verify -match 'customers\s+(\d+)')) $verify
|
||||||
|
|
||||||
|
Send $admin 'POST' '/settings' @{ warn_days = '3'; screen_refresh = '30' } | Out-Null
|
||||||
|
$keepShown = [int][regex]::Match((Send $admin 'GET' '/settings').Content, '保留 (\d+) 份').Groups[1].Value
|
||||||
|
for ($i = 0; $i -lt 3; $i++) {
|
||||||
|
Start-Sleep -Milliseconds 1200
|
||||||
|
Send $admin 'POST' '/settings/backup' | Out-Null
|
||||||
|
}
|
||||||
|
$bk = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\backup' -Filter 'daili-*.db')
|
||||||
|
$pruneOk = ($keepShown -ge 1) -and ($bk.Count -le $keepShown) -and (($keepShown -gt 3) -or ($bk.Count -eq $keepShown))
|
||||||
|
Check 'R-08' "备份保留份数生效(配置 $keepShown 份)" $pruneOk "count=$($bk.Count) keep=$keepShown"
|
||||||
|
|
||||||
|
Write-Host "`n=== 14. 非功能 / 安全(N) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
function TimeOf([string]$path) {
|
||||||
|
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
Send $admin 'GET' $path | Out-Null
|
||||||
|
$sw.Stop()
|
||||||
|
return $sw.ElapsedMilliseconds
|
||||||
|
}
|
||||||
|
$tCal = @(1..3 | ForEach-Object { TimeOf "/calendar?d=$today" } | Measure-Object -Minimum).Minimum
|
||||||
|
$tCust = @(1..3 | ForEach-Object { TimeOf '/customers' } | Measure-Object -Minimum).Minimum
|
||||||
|
$tScr = @(1..3 | ForEach-Object { TimeOf '/screen' } | Measure-Object -Minimum).Minimum
|
||||||
|
Check 'N-01' '页面响应时间达标' (($tCal -lt 150) -and ($tCust -lt 100) -and ($tScr -lt 100)) "calendar=${tCal}ms customers=${tCust}ms screen=${tScr}ms"
|
||||||
|
|
||||||
|
Write-Host " (造 300 条订阅后复测日历)" -ForegroundColor DarkGray
|
||||||
|
Dbtool @('bulk', $Db, '300') | Out-Null
|
||||||
|
$tBulk = @(1..3 | ForEach-Object { TimeOf "/calendar?d=$today" } | Measure-Object -Minimum).Minimum
|
||||||
|
Check 'N-02' '300 条订阅下日历仍快' ($tBulk -lt 500) "calendar=${tBulk}ms"
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/customers' @{ tail = '0002'; name = '<script>alert(1)</script>' }
|
||||||
|
$xssID = ($r.Path -split '/')[-1]
|
||||||
|
$xss = (Send $admin 'GET' "/customers/$xssID").Content
|
||||||
|
Check 'N-03' 'XSS 内容被转义' ((-not (Has $xss '<script>alert\(1\)</script>')) -and (Has $xss '<script>')) ""
|
||||||
|
|
||||||
|
$r = Send $admin 'GET' '/customers/999999'
|
||||||
|
Check 'N-04' '不存在的客户返回 404' ($r.Status -eq 404) "status=$($r.Status)"
|
||||||
|
$r = Send $admin 'GET' '/nope'
|
||||||
|
Check 'N-07' '未知路径返回 404' ($r.Status -eq 404) "status=$($r.Status)"
|
||||||
|
|
||||||
|
$hdr = (& curl.exe -s -i -X POST -d 'username=admin&password=daili123&next=/calendar' "$Base/login") -join "`n"
|
||||||
|
$hasHttpOnly = $hdr -match 'Set-Cookie: daili_sid=[^;]+;[^\r\n]*HttpOnly'
|
||||||
|
$hasSameSite = $hdr -match 'SameSite=Lax'
|
||||||
|
Check 'N-05' '会话 Cookie 含 HttpOnly + SameSite' ($hasHttpOnly -and $hasSameSite) "HttpOnly=$hasHttpOnly SameSite=$hasSameSite"
|
||||||
|
|
||||||
|
Write-Host "`n=== 15. 账户收尾(A) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/settings/users' @{ username = 'helper'; display_name = '小工'; password = 'helper123' }
|
||||||
|
Check 'A-08a' '新增第二个管理员' ((Has $r.Content '已创建')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
|
$helper = New-Sess
|
||||||
|
$r = Send $helper 'POST' '/login' @{ username = 'helper'; password = 'helper123'; next = '/customers' }
|
||||||
|
$helperView = (Send $helper 'GET' '/customers').Content
|
||||||
|
$helperN = [regex]::Match($helperView, '共 (\d+) 位').Groups[1].Value
|
||||||
|
$adminView = (Send $admin 'GET' '/customers').Content
|
||||||
|
$adminN = [regex]::Match($adminView, '共 (\d+) 位').Groups[1].Value
|
||||||
|
Check 'A-08b' '多管理员共享同一套数据' ($helperN -eq $adminN -and $helperN -ne '') "helper=$helperN admin=$adminN"
|
||||||
|
|
||||||
|
$settingsHtml = (Send $admin 'GET' '/settings').Content
|
||||||
|
$toggles = [regex]::Matches($settingsHtml, '/settings/users/\d+/toggle').Count
|
||||||
|
Check 'A-10' '不能停用自己的账户' ((Has $settingsHtml '当前账户') -and $toggles -eq 1) "toggles=$toggles"
|
||||||
|
|
||||||
|
$helperID = [regex]::Match($settingsHtml, '/settings/users/(\d+)/toggle').Groups[1].Value
|
||||||
|
$r = Send $admin 'POST' "/settings/users/$helperID/toggle"
|
||||||
|
Check 'A-09a' '停用账户' ((Has $r.Content '停用账户成功')) "flash=$(Flash $r.Content)"
|
||||||
|
$helper2 = New-Sess
|
||||||
|
$r = Send $helper2 'POST' '/login' @{ username = 'helper'; password = 'helper123'; next = '/calendar' }
|
||||||
|
Check 'A-09b' '停用后无法登录' ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确')) "status=$($r.Status)"
|
||||||
|
Send $admin 'POST' "/settings/users/$helperID/toggle" | Out-Null
|
||||||
|
|
||||||
|
$r = Send $admin 'POST' '/settings/password' @{ old_password = 'nope'; password = 'daili456'; password2 = 'daili456' }
|
||||||
|
Check 'A-11a' '原密码错误被拒' ((Has $r.Content '原密码不正确')) "flash=$(Flash $r.Content)"
|
||||||
|
$r = Send $admin 'POST' '/settings/password' @{ old_password = 'daili123'; password = 'daili456'; password2 = 'daili456' }
|
||||||
|
Check 'A-11b' '修改密码成功' ((Has $r.Content '密码已更新')) "flash=$(Flash $r.Content)"
|
||||||
|
$old = New-Sess
|
||||||
|
$r = Send $old 'POST' '/login' @{ username = 'admin'; password = 'daili123'; next = '/calendar' }
|
||||||
|
$oldFail = ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确'))
|
||||||
|
$new = New-Sess
|
||||||
|
$r = Send $new 'POST' '/login' @{ username = 'admin'; password = 'daili456'; next = '/calendar' }
|
||||||
|
Check 'A-11c' '旧密码失效、新密码可用' ($oldFail -and $r.Path -eq '/calendar') "oldFail=$oldFail path=$($r.Path)"
|
||||||
|
|
||||||
|
# 回滚口令,避免影响后续人工验证
|
||||||
|
Send $admin 'POST' '/settings/password' @{ old_password = 'daili456'; password = 'daili123'; password2 = 'daili123' } | Out-Null
|
||||||
|
|
||||||
|
Write-Host "`n=== 16. 配置文件(CF) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$settingsHtml = (Send $admin 'GET' '/settings').Content
|
||||||
|
Check 'CF-01' '设置页展示配置文件路径' ((Has $settingsHtml 'daili.ini')) ""
|
||||||
|
Check 'CF-02' '设置页展示备份目录与保留份数' ((Has $settingsHtml '备份') -and (Has $settingsHtml '保留')) ""
|
||||||
|
$anon2 = New-Sess
|
||||||
|
$r = Send $anon2 'GET' '/screen'
|
||||||
|
Check 'CF-03' '大屏免登录(screen_public = true)' ($r.Status -eq 200 -and $r.Path -eq '/screen') "status=$($r.Status) path=$($r.Path)"
|
||||||
|
$logs = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\logs' -Filter '*.log' -ErrorAction SilentlyContinue)
|
||||||
|
$logText = ''
|
||||||
|
if ($logs.Count -ge 1) { $logText = Get-Content $logs[0].FullName -Raw -Encoding UTF8 }
|
||||||
|
Check 'CF-04' '运行日志落盘' ($logs.Count -ge 1 -and $logText.Length -gt 20) "files=$($logs.Count) bytes=$($logText.Length)"
|
||||||
|
Write-Host "`n=== 汇总 ===" -ForegroundColor Cyan
|
||||||
|
$pass = @($script:results | Where-Object { $_.Result -eq 'PASS' }).Count
|
||||||
|
$fail = @($script:results | Where-Object { $_.Result -eq 'FAIL' }).Count
|
||||||
|
Write-Host (" 通过 {0} 项,失败 {1} 项,共 {2} 项" -f $pass, $fail, $script:results.Count) -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
|
||||||
|
|
||||||
|
$script:results | ForEach-Object { "{0}|{1}|{2}|{3}" -f $_.ID, $_.Result, $_.Name, $_.Detail } |
|
||||||
|
Set-Content -Path $LogFile -Encoding UTF8
|
||||||
|
Write-Host (" 结果已写入 {0}" -f $LogFile) -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
if ($fail -gt 0) {
|
||||||
|
Write-Host "`n失败明细:" -ForegroundColor Red
|
||||||
|
$script:results | Where-Object { $_.Result -eq 'FAIL' } | ForEach-Object {
|
||||||
|
Write-Host (" {0} {1} {2}" -f $_.ID, $_.Name, $_.Detail) -ForegroundColor Red
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 配置文件专项测试:验证 daili.ini 的生成、解析、覆盖与各项生效
|
||||||
|
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\config-test.ps1
|
||||||
|
param(
|
||||||
|
[string]$Exe = 'F:\hupeE的代肝平台\daili.exe',
|
||||||
|
[string]$Work = 'F:\hupeE的代肝平台\cfgtest-run',
|
||||||
|
[string]$LanIp = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
Add-Type -AssemblyName System.Net.Http
|
||||||
|
|
||||||
|
$script:fail = 0
|
||||||
|
$script:results = @()
|
||||||
|
function Check {
|
||||||
|
param([string]$Id, [string]$Name, $Cond, [string]$Detail = '')
|
||||||
|
if ($Cond -eq $true) {
|
||||||
|
Write-Host (" [PASS] {0} {1}" -f $Id, $Name) -ForegroundColor Green
|
||||||
|
$script:results += "PASS|$Id|$Name|$Detail"
|
||||||
|
} else {
|
||||||
|
Write-Host (" [FAIL] {0} {1} {2}" -f $Id, $Name, $Detail) -ForegroundColor Red
|
||||||
|
$script:results += "FAIL|$Id|$Name|$Detail"
|
||||||
|
$script:fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-Client {
|
||||||
|
$h = New-Object System.Net.Http.HttpClientHandler
|
||||||
|
$h.UseCookies = $true
|
||||||
|
$h.AllowAutoRedirect = $true
|
||||||
|
$c = New-Object System.Net.Http.HttpClient($h)
|
||||||
|
$c.Timeout = [TimeSpan]::FromSeconds(10)
|
||||||
|
return $c
|
||||||
|
}
|
||||||
|
|
||||||
|
function Post-Form($client, $url, $kv) {
|
||||||
|
$pairs = New-Object 'System.Collections.Generic.List[System.Collections.Generic.KeyValuePair[string,string]]'
|
||||||
|
foreach ($k in $kv.Keys) {
|
||||||
|
$pairs.Add([System.Collections.Generic.KeyValuePair[string,string]]::new($k, [string]$kv[$k]))
|
||||||
|
}
|
||||||
|
return $client.PostAsync($url, [System.Net.Http.FormUrlEncodedContent]::new($pairs)).Result
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Status($client, $url) {
|
||||||
|
try {
|
||||||
|
$r = $client.GetAsync($url).Result
|
||||||
|
return @{ Code = [int]$r.StatusCode; Path = $r.RequestMessage.RequestUri.AbsolutePath; Body = $r.Content.ReadAsStringAsync().Result }
|
||||||
|
} catch {
|
||||||
|
return @{ Code = 0; Path = ''; Body = '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Up([int]$port, [int]$seconds = 15) {
|
||||||
|
$c = New-Client
|
||||||
|
for ($i = 0; $i -lt ($seconds * 4); $i++) {
|
||||||
|
$r = Get-Status $c "http://127.0.0.1:$port/healthz"
|
||||||
|
if ($r.Code -eq 200) { return $true }
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-App([string]$dir, [string[]]$extra, [int]$port) {
|
||||||
|
$out = Join-Path $dir 'stdout.log'
|
||||||
|
$err = Join-Path $dir 'stderr.log'
|
||||||
|
$p = Start-Process -FilePath (Join-Path $dir 'daili.exe') -ArgumentList $extra -WorkingDirectory $dir `
|
||||||
|
-WindowStyle Hidden -PassThru -RedirectStandardOutput $out -RedirectStandardError $err
|
||||||
|
$ok = Wait-Up $port
|
||||||
|
return @{ Proc = $p; Out = $out; Err = $err; Up = $ok }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-App($h) {
|
||||||
|
if ($h -and $h.Proc) { Stop-Process -Id $h.Proc.Id -Force -ErrorAction SilentlyContinue }
|
||||||
|
Start-Sleep -Milliseconds 600
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-CaseDir([string]$name) {
|
||||||
|
$dir = Join-Path $Work $name
|
||||||
|
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
Copy-Item $Exe (Join-Path $dir 'daili.exe') -Force
|
||||||
|
return $dir
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Conf([string]$dir, [string]$text) {
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $dir 'daili.ini'), $text, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $LanIp) {
|
||||||
|
$LanIp = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } |
|
||||||
|
Select-Object -First 1 -ExpandProperty IPAddress)
|
||||||
|
}
|
||||||
|
if (-not $LanIp) { $LanIp = '192.168.1.1' }
|
||||||
|
|
||||||
|
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force -Path $Work | Out-Null
|
||||||
|
|
||||||
|
Write-Host "`n=== 配置文件专项测试(LAN IP: $LanIp) ===" -ForegroundColor Cyan
|
||||||
|
$c = New-Client
|
||||||
|
|
||||||
|
# ---------- CFG-01 / CFG-02:首启生成默认配置 + 默认端口 17777 ----------
|
||||||
|
Write-Host "`n-- CFG-01/02 首次启动生成默认配置 --" -ForegroundColor Cyan
|
||||||
|
$d1 = New-CaseDir 'default'
|
||||||
|
$h = Start-App $d1 @('-no-open') 17777
|
||||||
|
$ini = Join-Path $d1 'daili.ini'
|
||||||
|
Check 'CFG-01a' '首次启动自动生成 daili.ini' (Test-Path $ini)
|
||||||
|
$iniText = Get-Content $ini -Raw -Encoding UTF8
|
||||||
|
Check 'CFG-01b' '默认配置含注释与默认监听' (($iniText -match '代打台 配置文件') -and ($iniText -match 'listen_ip = 0\.0\.0\.0') -and ($iniText -match 'listen_port = 17777') -and ($iniText -match 'screen_public = true'))
|
||||||
|
$r = Get-Status $c 'http://127.0.0.1:17777/healthz'
|
||||||
|
Check 'CFG-02' '默认监听 17777 可用' ($h.Up -and $r.Code -eq 200) "health=$($r.Code)"
|
||||||
|
Stop-App $h
|
||||||
|
$logDir = Join-Path $d1 'logs'
|
||||||
|
$logs = @(Get-ChildItem $logDir -Filter 'daili-*.log' -ErrorAction SilentlyContinue)
|
||||||
|
$logText = ''
|
||||||
|
if ($logs.Count -ge 1) { $logText = Get-Content $logs[0].FullName -Raw -Encoding UTF8 }
|
||||||
|
Check 'CFG-07' '运行日志按天落盘' ($logs.Count -ge 1 -and $logText.Length -gt 20) "files=$($logs.Count) bytes=$($logText.Length)"
|
||||||
|
|
||||||
|
# ---------- CFG-03:只监听本机 ----------
|
||||||
|
Write-Host "`n-- CFG-03 仅本机监听 --" -ForegroundColor Cyan
|
||||||
|
$d2 = New-CaseDir 'loopback'
|
||||||
|
Write-Conf $d2 @"
|
||||||
|
listen_ip = 127.0.0.1
|
||||||
|
listen_port = 17901
|
||||||
|
open_browser = false
|
||||||
|
log_enabled = false
|
||||||
|
backup_enabled = false
|
||||||
|
"@
|
||||||
|
$h = Start-App $d2 @('-no-open') 17901
|
||||||
|
$local = (Test-NetConnection -ComputerName 127.0.0.1 -Port 17901 -InformationLevel Quiet -WarningAction SilentlyContinue)
|
||||||
|
$lan = (Test-NetConnection -ComputerName $LanIp -Port 17901 -InformationLevel Quiet -WarningAction SilentlyContinue)
|
||||||
|
Check 'CFG-03' '127.0.0.1 只开本机,局域网不可连' ($h.Up -and $local -and -not $lan) "local=$local lan=$lan"
|
||||||
|
$banner = Get-Content (Join-Path $d2 'stdout.log') -Raw -Encoding UTF8
|
||||||
|
Check 'CFG-03b' '横幅不再打印局域网地址' (-not ($banner -match '局域网')) ""
|
||||||
|
Stop-App $h
|
||||||
|
|
||||||
|
# ---------- CFG-04:非法配置报错退出且不改文件 ----------
|
||||||
|
Write-Host "`n-- CFG-04 非法配置 --" -ForegroundColor Cyan
|
||||||
|
$d3 = New-CaseDir 'invalid'
|
||||||
|
Write-Conf $d3 "# 故意写错`nlisten_ip = 0.0.0.0`nlisten_port = 99999`n"
|
||||||
|
$badIni = Join-Path $d3 'daili.ini'
|
||||||
|
$hashBefore = (Get-FileHash $badIni -Algorithm SHA256).Hash
|
||||||
|
$outText = & (Join-Path $d3 'daili.exe') -no-open 2>&1 | Out-String
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
$hashAfter = (Get-FileHash $badIni -Algorithm SHA256).Hash
|
||||||
|
Check 'CFG-04a' '非法配置退出码为 1' ($code -eq 1) "code=$code"
|
||||||
|
Check 'CFG-04b' '错误信息指出行号' ($outText -match '第 3 行' -and $outText -match '1-65535') ""
|
||||||
|
Check 'CFG-04c' '原配置文件未被改动' ($hashBefore -eq $hashAfter) ""
|
||||||
|
|
||||||
|
# ---------- CFG-05:命令行覆盖配置 ----------
|
||||||
|
Write-Host "`n-- CFG-05 命令行覆盖 --" -ForegroundColor Cyan
|
||||||
|
$d4 = New-CaseDir 'override'
|
||||||
|
Write-Conf $d4 @"
|
||||||
|
listen_ip = 127.0.0.1
|
||||||
|
listen_port = 17902
|
||||||
|
open_browser = false
|
||||||
|
log_enabled = false
|
||||||
|
backup_enabled = false
|
||||||
|
"@
|
||||||
|
$h = Start-App $d4 @('-no-open', '-addr', '127.0.0.1:17903') 17903
|
||||||
|
$p17902 = (Test-NetConnection -ComputerName 127.0.0.1 -Port 17902 -InformationLevel Quiet -WarningAction SilentlyContinue)
|
||||||
|
Check 'CFG-05' '-addr 覆盖配置文件端口' ($h.Up -and -not $p17902) "up17903=$($h.Up) up17902=$p17902"
|
||||||
|
Stop-App $h
|
||||||
|
|
||||||
|
# ---------- CFG-06:大屏鉴权开关 ----------
|
||||||
|
Write-Host "`n-- CFG-06 大屏访问控制 --" -ForegroundColor Cyan
|
||||||
|
$d5 = New-CaseDir 'screen-private'
|
||||||
|
Write-Conf $d5 @"
|
||||||
|
listen_ip = 127.0.0.1
|
||||||
|
listen_port = 17904
|
||||||
|
open_browser = false
|
||||||
|
screen_public = false
|
||||||
|
log_enabled = false
|
||||||
|
backup_enabled = false
|
||||||
|
"@
|
||||||
|
$h = Start-App $d5 @('-no-open') 17904
|
||||||
|
$anon = New-Client
|
||||||
|
$r = Get-Status $anon 'http://127.0.0.1:17904/screen'
|
||||||
|
Check 'CFG-06' 'screen_public=false 时大屏需登录' (($r.Path -like '/login*') -or ($r.Path -eq '/setup')) "path=$($r.Path)"
|
||||||
|
Stop-App $h
|
||||||
|
|
||||||
|
# ---------- CFG-08:备份目录与保留份数来自配置 ----------
|
||||||
|
Write-Host "`n-- CFG-08 备份目录与保留份数 --" -ForegroundColor Cyan
|
||||||
|
$d6 = New-CaseDir 'backup'
|
||||||
|
$bdir = Join-Path $d6 'my-backup'
|
||||||
|
Write-Conf $d6 @"
|
||||||
|
listen_ip = 127.0.0.1
|
||||||
|
listen_port = 17905
|
||||||
|
open_browser = false
|
||||||
|
log_enabled = false
|
||||||
|
backup_enabled = true
|
||||||
|
backup_dir = $bdir
|
||||||
|
backup_keep = 1
|
||||||
|
"@
|
||||||
|
$h = Start-App $d6 @('-no-open') 17905
|
||||||
|
$c6 = New-Client
|
||||||
|
Post-Form $c6 'http://127.0.0.1:17905/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili123' } | Out-Null
|
||||||
|
Post-Form $c6 'http://127.0.0.1:17905/packages' @{ name = '包月日常'; unit = 'day'; default_amount = '30'; color = '#2F5D50'; sort_order = '1' } | Out-Null
|
||||||
|
Post-Form $c6 'http://127.0.0.1:17905/customers' @{ tail = '3001'; name = '备份客户'; game_name = ''; game_server = ''; contact = ''; tags = ''; source = ''; note = '' } | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 1200
|
||||||
|
Post-Form $c6 'http://127.0.0.1:17905/settings/backup' @{} | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 1200
|
||||||
|
Post-Form $c6 'http://127.0.0.1:17905/settings/backup' @{} | Out-Null
|
||||||
|
$files = @(Get-ChildItem $bdir -Filter '*.db' -ErrorAction SilentlyContinue)
|
||||||
|
Check 'CFG-08a' '备份写入配置指定的目录' ($files.Count -ge 1) "dir=$bdir files=$($files.Count)"
|
||||||
|
Check 'CFG-08b' 'backup_keep=1 时只保留 1 份' ($files.Count -eq 1) "files=$($files.Count)"
|
||||||
|
Stop-App $h
|
||||||
|
|
||||||
|
# ---------- CFG-09:时区固定 ----------
|
||||||
|
Write-Host "`n-- CFG-09 时区固定 --" -ForegroundColor Cyan
|
||||||
|
$d7 = New-CaseDir 'timezone'
|
||||||
|
Write-Conf $d7 @"
|
||||||
|
listen_ip = 127.0.0.1
|
||||||
|
listen_port = 17906
|
||||||
|
open_browser = false
|
||||||
|
log_enabled = true
|
||||||
|
backup_enabled = false
|
||||||
|
timezone = Asia/Shanghai
|
||||||
|
"@
|
||||||
|
$h = Start-App $d7 @('-no-open') 17906
|
||||||
|
$errText = Get-Content (Join-Path $d7 'stderr.log') -Raw -Encoding UTF8
|
||||||
|
Check 'CFG-09' '时区固定为 Asia/Shanghai 生效' ($errText -match '时区已固定为 Asia/Shanghai') ""
|
||||||
|
Stop-App $h
|
||||||
|
|
||||||
|
# ---------- 汇总 ----------
|
||||||
|
$pass = @($script:results | Where-Object { $_ -like 'PASS|*' }).Count
|
||||||
|
$fail = @($script:results | Where-Object { $_ -like 'FAIL|*' }).Count
|
||||||
|
Write-Host "`n=== 汇总:通过 $pass 项,失败 $fail 项 ===" -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
|
||||||
|
New-Item -ItemType Directory -Force -Path 'F:\hupeE的代肝平台\test-evidence' | Out-Null
|
||||||
|
$script:results | Set-Content 'F:\hupeE的代肝平台\test-evidence\config-result.txt' -Encoding UTF8
|
||||||
|
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
||||||
|
if ($fail -gt 0) { exit 1 }
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""测试辅助:直接读写测试库,用于构造 HTTP 层做不到的前置条件。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python dbtool.py backdate-pause <db> <sub_id> <days> 把暂停开始时间提前 N 天
|
||||||
|
python dbtool.py bulk <db> <count> 批量造客户+按日订阅(包月日常)
|
||||||
|
python dbtool.py exec <db> <sql> 执行一条 SQL
|
||||||
|
python dbtool.py scalar <db> <sql> 输出第一条结果的第一列
|
||||||
|
python dbtool.py integrity <db> 完整性检查
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def connect(path):
|
||||||
|
con = sqlite3.connect(path, timeout=10)
|
||||||
|
con.execute("PRAGMA busy_timeout=10000")
|
||||||
|
con.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return con
|
||||||
|
|
||||||
|
|
||||||
|
def backdate_pause(db, sub_id, days):
|
||||||
|
con = connect(db)
|
||||||
|
now = datetime.datetime.now() - datetime.timedelta(days=int(days))
|
||||||
|
stamp = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cur = con.execute(
|
||||||
|
"UPDATE sub_pauses SET start_at=? WHERE sub_id=? AND end_at IS NULL",
|
||||||
|
(stamp, int(sub_id)))
|
||||||
|
con.commit()
|
||||||
|
print("updated rows:", cur.rowcount, "start_at:", stamp)
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def bulk(db, count):
|
||||||
|
con = connect(db)
|
||||||
|
today = datetime.date.today()
|
||||||
|
start = (today - datetime.timedelta(days=5)).isoformat()
|
||||||
|
end = (today + datetime.timedelta(days=25)).isoformat()
|
||||||
|
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
pkg = con.execute("SELECT id FROM packages WHERE unit='day' ORDER BY id LIMIT 1").fetchone()
|
||||||
|
if not pkg:
|
||||||
|
print("FAIL: 没有按日套餐")
|
||||||
|
sys.exit(1)
|
||||||
|
pkg_id = pkg[0]
|
||||||
|
created = 0
|
||||||
|
for i in range(int(count)):
|
||||||
|
tail = "B%04d" % i
|
||||||
|
con.execute(
|
||||||
|
"INSERT OR IGNORE INTO customers(tail,name,created_at,updated_at) VALUES(?,?,?,?)",
|
||||||
|
(tail, "批量客户%d" % i, stamp, stamp))
|
||||||
|
row = con.execute("SELECT id FROM customers WHERE tail=?", (tail,)).fetchone()
|
||||||
|
cid = row[0]
|
||||||
|
con.execute(
|
||||||
|
"INSERT OR IGNORE INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,"
|
||||||
|
"total_count,used_count,created_at,updated_at) VALUES(?,?,'day','active',?,?,0,0,?,?)",
|
||||||
|
(cid, pkg_id, start, end, stamp, stamp))
|
||||||
|
created += 1
|
||||||
|
con.commit()
|
||||||
|
total = con.execute("SELECT COUNT(*) FROM subscriptions").fetchone()[0]
|
||||||
|
print("bulk customers:", created, "subscriptions total:", total)
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(2)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
db = sys.argv[2]
|
||||||
|
if cmd == "backdate-pause":
|
||||||
|
backdate_pause(db, sys.argv[3], sys.argv[4])
|
||||||
|
elif cmd == "bulk":
|
||||||
|
bulk(db, sys.argv[3])
|
||||||
|
elif cmd == "exec":
|
||||||
|
con = connect(db)
|
||||||
|
cur = con.execute(sys.argv[3])
|
||||||
|
con.commit()
|
||||||
|
print("rows:", cur.rowcount)
|
||||||
|
con.close()
|
||||||
|
elif cmd == "scalar":
|
||||||
|
con = connect(db)
|
||||||
|
print(con.execute(sys.argv[3]).fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
elif cmd == "integrity":
|
||||||
|
con = connect(db)
|
||||||
|
print(con.execute("PRAGMA integrity_check").fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
else:
|
||||||
|
print("未知命令:", cmd)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 数据可移植性测试:单文件、只拷 daili.db 无损迁移、删库即重置
|
||||||
|
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\portability-test.ps1
|
||||||
|
param(
|
||||||
|
[string]$Exe = 'F:\hupeE的代肝平台\daili.exe',
|
||||||
|
[string]$Work = 'F:\hupeE的代肝平台\portable-run',
|
||||||
|
[int]$PortA = 17781,
|
||||||
|
[int]$PortB = 17782
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
Add-Type -AssemblyName System.Net.Http
|
||||||
|
|
||||||
|
$script:fail = 0
|
||||||
|
$script:results = @()
|
||||||
|
function Check {
|
||||||
|
param([string]$Id, [string]$Name, $Cond, [string]$Detail = '')
|
||||||
|
if ($Cond -eq $true) {
|
||||||
|
Write-Host (" [PASS] {0} {1}" -f $Id, $Name) -ForegroundColor Green
|
||||||
|
$script:results += "PASS|$Id|$Name|$Detail"
|
||||||
|
} else {
|
||||||
|
Write-Host (" [FAIL] {0} {1} {2}" -f $Id, $Name, $Detail) -ForegroundColor Red
|
||||||
|
$script:results += "FAIL|$Id|$Name|$Detail"
|
||||||
|
$script:fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-Client {
|
||||||
|
$h = New-Object System.Net.Http.HttpClientHandler
|
||||||
|
$h.UseCookies = $true
|
||||||
|
$h.AllowAutoRedirect = $true
|
||||||
|
$c = New-Object System.Net.Http.HttpClient($h)
|
||||||
|
$c.Timeout = [TimeSpan]::FromSeconds(10)
|
||||||
|
return $c
|
||||||
|
}
|
||||||
|
|
||||||
|
function Post-Form($client, $url, $kv) {
|
||||||
|
$pairs = New-Object 'System.Collections.Generic.List[System.Collections.Generic.KeyValuePair[string,string]]'
|
||||||
|
foreach ($k in $kv.Keys) { $pairs.Add([System.Collections.Generic.KeyValuePair[string,string]]::new($k, [string]$kv[$k])) }
|
||||||
|
return $client.PostAsync($url, [System.Net.Http.FormUrlEncodedContent]::new($pairs)).Result
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Page($client, $url) {
|
||||||
|
try {
|
||||||
|
$r = $client.GetAsync($url).Result
|
||||||
|
return @{ Code = [int]$r.StatusCode; Path = $r.RequestMessage.RequestUri.AbsolutePath; Body = $r.Content.ReadAsStringAsync().Result }
|
||||||
|
} catch { return @{ Code = 0; Path = ''; Body = '' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Up([int]$port, [int]$seconds = 15) {
|
||||||
|
$c = New-Client
|
||||||
|
for ($i = 0; $i -lt ($seconds * 4); $i++) {
|
||||||
|
if ((Get-Page $c "http://127.0.0.1:$port/healthz").Code -eq 200) { return $true }
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-App([string]$dir, [int]$port) {
|
||||||
|
$p = Start-Process -FilePath (Join-Path $dir 'daili.exe') -ArgumentList @('-addr', "127.0.0.1:$port", '-no-open') `
|
||||||
|
-WorkingDirectory $dir -WindowStyle Hidden -PassThru `
|
||||||
|
-RedirectStandardOutput (Join-Path $dir 'stdout.log') -RedirectStandardError (Join-Path $dir 'stderr.log')
|
||||||
|
return @{ Proc = $p; Up = (Wait-Up $port) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-App($h) {
|
||||||
|
if ($h -and $h.Proc) { Stop-Process -Id $h.Proc.Id -Force -ErrorAction SilentlyContinue }
|
||||||
|
Start-Sleep -Milliseconds 700
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-AppDir([string]$name) {
|
||||||
|
$dir = Join-Path $Work $name
|
||||||
|
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
Copy-Item $Exe (Join-Path $dir 'daili.exe') -Force
|
||||||
|
return $dir
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force -Path $Work | Out-Null
|
||||||
|
$today = (Get-Date).Date.ToString('yyyy-MM-dd')
|
||||||
|
$newEnd = (Get-Date).Date.AddDays(45).ToString('yyyy-MM-dd')
|
||||||
|
|
||||||
|
Write-Host "`n=== 数据可移植性测试 ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# ---------- 1. A 目录造数据 ----------
|
||||||
|
Write-Host "`n-- 1. 在 A 目录造数据 --" -ForegroundColor Cyan
|
||||||
|
$dirA = New-AppDir 'A'
|
||||||
|
$hA = Start-App $dirA $PortA
|
||||||
|
Check 'P-01' 'A 目录启动成功' $hA.Up ""
|
||||||
|
|
||||||
|
$cA = New-Client
|
||||||
|
Post-Form $cA "http://127.0.0.1:$PortA/setup" @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili123' } | Out-Null
|
||||||
|
Post-Form $cA "http://127.0.0.1:$PortA/packages" @{ name = '包月日常'; unit = 'day'; default_amount = '30'; price = '300'; color = '#2F5D50'; note = ''; sort_order = '1' } | Out-Null
|
||||||
|
Post-Form $cA "http://127.0.0.1:$PortA/customers" @{ tail = '8001'; name = '迁移客户'; game_name = '王者荣耀'; game_server = ''; contact = 'wx:8001'; tags = '老客户'; source = ''; note = '迁移前数据' } | Out-Null
|
||||||
|
Post-Form $cA "http://127.0.0.1:$PortA/customers/1/subscriptions" @{ package_id = '1'; start_date = $today; days = '30'; note = '首单' } | Out-Null
|
||||||
|
$detail = (Get-Page $cA "http://127.0.0.1:$PortA/customers/1").Body
|
||||||
|
$subId = [regex]::Match($detail, 'id="sub-(\d+)"').Groups[1].Value
|
||||||
|
Post-Form $cA "http://127.0.0.1:$PortA/subscriptions/$subId/extend" @{ new_end_date = $newEnd; note = '迁移前续费'; back = '/customers/1' } | Out-Null
|
||||||
|
Post-Form $cA "http://127.0.0.1:$PortA/subscriptions/$subId/checkin" @{ date = $today; status = 'done'; note = '迁移前打卡'; back = '/customers/1' } | Out-Null
|
||||||
|
$detail = (Get-Page $cA "http://127.0.0.1:$PortA/customers/1").Body
|
||||||
|
Check 'P-02' '造数成功(剩 46 天 + 打卡)' ($detail -match '剩 46 天' -and $detail -match '迁移前打卡') ""
|
||||||
|
|
||||||
|
Stop-App $hA
|
||||||
|
|
||||||
|
# ---------- 2. 关闭后应当只有一个数据库文件 ----------
|
||||||
|
Write-Host "`n-- 2. 关闭后检查文件形态 --" -ForegroundColor Cyan
|
||||||
|
$dbA = Join-Path $dirA 'daili.db'
|
||||||
|
$sidecars = @()
|
||||||
|
foreach ($s in '-wal', '-shm', '-journal') {
|
||||||
|
if (Test-Path ($dbA + $s)) { $sidecars += $s }
|
||||||
|
}
|
||||||
|
Check 'P-03' '关闭后只有 daili.db(无 -wal/-shm/-journal)' ((Test-Path $dbA) -and $sidecars.Count -eq 0) "sidecars=$($sidecars -join ',')"
|
||||||
|
$sizeA = (Get-Item $dbA).Length
|
||||||
|
Check 'P-04' '主库文件包含全部数据(远大于空库 4KB)' ($sizeA -gt 20000) "size=$sizeA"
|
||||||
|
|
||||||
|
# ---------- 3. 只拷贝 daili.db 到 B 目录 ----------
|
||||||
|
Write-Host "`n-- 3. 只拷贝 daili.db 到 B 目录 --" -ForegroundColor Cyan
|
||||||
|
$dirB = New-AppDir 'B'
|
||||||
|
Copy-Item $dbA (Join-Path $dirB 'daili.db') -Force
|
||||||
|
$hB = Start-App $dirB $PortB
|
||||||
|
Check 'P-05' 'B 目录用拷贝的库启动成功' $hB.Up ""
|
||||||
|
|
||||||
|
$cB = New-Client
|
||||||
|
$login = Post-Form $cB "http://127.0.0.1:$PortB/login" @{ username = 'admin'; password = 'daili123'; next = '/customers' }
|
||||||
|
$page = (Get-Page $cB "http://127.0.0.1:$PortB/customers").Body
|
||||||
|
Check 'P-06' '原管理员账号可用(口令随库迁移)' ($page -match '共 1 位' -and $page -match '迁移客户') ""
|
||||||
|
|
||||||
|
$detailB = (Get-Page $cB "http://127.0.0.1:$PortB/customers/1").Body
|
||||||
|
Check 'P-07' '订阅与剩余天数完整' ($detailB -match '剩 46 天') ""
|
||||||
|
Check 'P-08' '打卡与追加记录完整' ($detailB -match '迁移前打卡' -and $detailB -match '迁移前续费') ""
|
||||||
|
Check 'P-09' '套餐配置完整' ((Get-Page $cB "http://127.0.0.1:$PortB/packages").Body -match '包月日常') ""
|
||||||
|
$logs = (Get-Page $cB "http://127.0.0.1:$PortB/logs").Body
|
||||||
|
Check 'P-10' '操作日志完整' ($logs -match '追加时长' -and $logs -match '打卡') ""
|
||||||
|
|
||||||
|
Stop-App $hB
|
||||||
|
for ($i = 0; $i -lt 10 -and (Test-Path ($dbA + '-wal')); $i++) { Start-Sleep -Milliseconds 200 }
|
||||||
|
$bSide = @()
|
||||||
|
foreach ($s in '-wal', '-shm', '-journal') { if (Test-Path ((Join-Path $dirB 'daili.db') + $s)) { $bSide += $s } }
|
||||||
|
Check 'P-11' 'B 目录运行后仍无旁路文件' ($bSide.Count -eq 0) "sidecars=$($bSide -join ',')"
|
||||||
|
|
||||||
|
# ---------- 4. 删除 daili.db = 重置 ----------
|
||||||
|
Write-Host "`n-- 4. 删除 daili.db 重置数据 --" -ForegroundColor Cyan
|
||||||
|
foreach ($s in '', '-wal', '-shm', '-journal') { Remove-Item ($dbA + $s) -Force -ErrorAction SilentlyContinue }
|
||||||
|
$hA2 = Start-App $dirA $PortA
|
||||||
|
Check 'P-12' '删库后可正常启动' $hA2.Up ""
|
||||||
|
$cA2 = New-Client
|
||||||
|
$root = Get-Page $cA2 "http://127.0.0.1:$PortA/"
|
||||||
|
Check 'P-13' '删库后回到初始化(数据已清空)' ($root.Path -eq '/setup' -and $root.Body -match '创建第一个管理员') "path=$($root.Path)"
|
||||||
|
Stop-App $hA2
|
||||||
|
|
||||||
|
# ---------- 汇总 ----------
|
||||||
|
$pass = @($script:results | Where-Object { $_ -like 'PASS|*' }).Count
|
||||||
|
$fail = @($script:results | Where-Object { $_ -like 'FAIL|*' }).Count
|
||||||
|
Write-Host "`n=== 汇总:通过 $pass 项,失败 $fail 项 ===" -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
|
||||||
|
New-Item -ItemType Directory -Force -Path 'F:\hupeE的代肝平台\test-evidence' | Out-Null
|
||||||
|
$script:results | Set-Content 'F:\hupeE的代肝平台\test-evidence\portability-result.txt' -Encoding UTF8
|
||||||
|
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
||||||
|
if ($fail -gt 0) { exit 1 }
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 生成演示数据(便于看效果、试用手感;可对空库反复运行)
|
||||||
|
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\seed.ps1 -Base http://127.0.0.1:18080
|
||||||
|
param(
|
||||||
|
[string]$Base = 'http://127.0.0.1:18080',
|
||||||
|
[string]$User = 'admin',
|
||||||
|
[string]$Pass = 'daili123'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
||||||
|
|
||||||
|
function Send {
|
||||||
|
param([string]$Method, [string]$Path, $Body)
|
||||||
|
$p = @{ Uri = "$Base$Path"; WebSession = $session; UseBasicParsing = $true; MaximumRedirection = 5; Method = $Method }
|
||||||
|
if ($null -ne $Body) { $p.Body = $Body }
|
||||||
|
try { Invoke-WebRequest @p } catch [System.Net.WebException] { $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = Get-Date
|
||||||
|
function D([int]$offset) { $now.AddDays($offset).ToString('yyyy-MM-dd') }
|
||||||
|
|
||||||
|
# 登录
|
||||||
|
$r = Send 'GET' '/setup'
|
||||||
|
if ($r -and $r.Content -match '创建第一个管理员') {
|
||||||
|
Send 'POST' '/setup' @{ username = $User; display_name = '老板'; password = $Pass; password2 = $Pass } | Out-Null
|
||||||
|
} else {
|
||||||
|
Send 'POST' '/login' @{ username = $User; password = $Pass; next = '/calendar' } | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 套餐(1、2 已存在则跳过创建)
|
||||||
|
$pkgs = @(
|
||||||
|
@{ name = '包月日常'; unit = 'day'; amount = 30; price = 300; color = '#2F5D50'; note = '每天日常 + 周常' },
|
||||||
|
@{ name = '深渊十次'; unit = 'count'; amount = 10; price = 180; color = '#6B4E9B'; note = '按次结算,打完扣次' },
|
||||||
|
@{ name = '赛季深渊'; unit = 'day'; amount = 90; price = 900; color = '#B45309'; note = '整赛季包干' },
|
||||||
|
@{ name = '每周副本'; unit = 'count'; amount = 5; price = 120; color = '#41556B'; note = '每周 5 次' }
|
||||||
|
)
|
||||||
|
foreach ($p in $pkgs) {
|
||||||
|
Send 'POST' '/packages' @{
|
||||||
|
name = $p.name; unit = $p.unit; default_amount = "$($p.amount)"
|
||||||
|
price = "$($p.price)"; color = $p.color; note = $p.note; sort_order = '1'
|
||||||
|
} | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 客户 + 订阅
|
||||||
|
$demo = @(
|
||||||
|
@{ tail = '1204'; name = '小林'; pkg = 1; start = -12; days = 30; note = '日常 + 周常' },
|
||||||
|
@{ tail = '5533'; name = '阿飞'; pkg = 1; start = -28; days = 30; note = '快到期,提醒续费' },
|
||||||
|
@{ tail = '9088'; name = '老王'; pkg = 1; start = -30; days = 30; note = '今天到期' },
|
||||||
|
@{ tail = '4321'; name = '妹妹'; pkg = 3; start = -60; days = 90; note = '整赛季' },
|
||||||
|
@{ tail = '7788'; name = '大鹏'; pkg = 2; start = -5; count = 10; end = 3; note = '按次' },
|
||||||
|
@{ tail = '6677'; name = '猫猫'; pkg = 4; start = -2; count = 5; end = 20; note = '每周副本' },
|
||||||
|
@{ tail = '3321'; name = '阿豪'; pkg = 1; start = -40; days = 30; note = '已过期两周' },
|
||||||
|
@{ tail = '9900'; name = '小雨'; pkg = 3; start = 2; days = 90; note = '还没开始' }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($d in $demo) {
|
||||||
|
$r = Send 'POST' '/customers' @{
|
||||||
|
tail = $d.tail; name = $d.name; game_name = '王者荣耀'; game_server = '安卓微信区'
|
||||||
|
contact = ('wx: ' + $d.tail); tags = '老客户'; source = '朋友介绍'; note = $d.note
|
||||||
|
}
|
||||||
|
$path = $r.BaseResponse.ResponseUri.AbsolutePath
|
||||||
|
if ($path -notlike '/customers/*') { continue }
|
||||||
|
$id = ($path -split '/')[-1]
|
||||||
|
if ($d.days) {
|
||||||
|
Send 'POST' "/customers/$id/subscriptions" @{
|
||||||
|
package_id = "$($d.pkg)"; start_date = (D $d.start); days = "$($d.days)"; note = $d.note
|
||||||
|
} | Out-Null
|
||||||
|
} else {
|
||||||
|
Send 'POST' "/customers/$id/subscriptions" @{
|
||||||
|
package_id = "$($d.pkg)"; start_date = (D $d.start); total_count = "$($d.count)"; end_date = (D $d.end); note = $d.note
|
||||||
|
} | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 今天的两条打卡
|
||||||
|
$r = Send 'GET' '/customers?q=1204'
|
||||||
|
if ($r.Content -match '/customers/(\d+)') {
|
||||||
|
$cid = $matches[1]
|
||||||
|
$r2 = Send 'GET' "/customers/$cid"
|
||||||
|
$ids = [regex]::Matches($r2.Content, 'id="sub-(\d+)"') | ForEach-Object { $_.Groups[1].Value }
|
||||||
|
if ($ids.Count -gt 0) {
|
||||||
|
Send 'POST' "/subscriptions/$($ids[0])/checkin" @{ date = (D 0); status = 'done'; note = '今日已打'; back = '/calendar' } | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$r = Send 'GET' '/customers?q=4321'
|
||||||
|
if ($r.Content -match '/customers/(\d+)') {
|
||||||
|
$cid = $matches[1]
|
||||||
|
$r2 = Send 'GET' "/customers/$cid"
|
||||||
|
$ids = [regex]::Matches($r2.Content, 'id="sub-(\d+)"') | ForEach-Object { $_.Groups[1].Value }
|
||||||
|
if ($ids.Count -gt 0) {
|
||||||
|
Send 'POST' "/subscriptions/$($ids[0])/checkin" @{ date = (D 0); status = 'done'; note = '深渊打完'; back = '/calendar' } | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host '演示数据已写入。' -ForegroundColor Green
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 用无头 Chrome 截图,做视觉验收
|
||||||
|
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\shots.ps1 -Base http://127.0.0.1:18080
|
||||||
|
param(
|
||||||
|
[string]$Base = 'http://127.0.0.1:18080',
|
||||||
|
[string]$User = 'admin',
|
||||||
|
[string]$Pass = 'daili123',
|
||||||
|
[string]$OutDir = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
if (-not $OutDir) { $OutDir = Join-Path $root 'shots' }
|
||||||
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||||
|
|
||||||
|
$chrome = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
|
||||||
|
if (-not (Test-Path $chrome)) { throw '找不到 Chrome' }
|
||||||
|
|
||||||
|
# 1) 登录拿会话 cookie
|
||||||
|
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
||||||
|
Invoke-WebRequest -Uri "$Base/login" -WebSession $session -UseBasicParsing | Out-Null
|
||||||
|
Invoke-WebRequest -Uri "$Base/login" -Method POST -WebSession $session -UseBasicParsing -Body @{
|
||||||
|
username = $User; password = $Pass; next = '/calendar'
|
||||||
|
} | Out-Null
|
||||||
|
$cookie = ($session.Cookies.GetCookies($Base) | Where-Object { $_.Name -eq 'daili_sid' } | Select-Object -First 1)
|
||||||
|
if (-not $cookie) { throw '登录失败,拿不到会话' }
|
||||||
|
$token = $cookie.Value
|
||||||
|
|
||||||
|
# 2) 拿一个订阅 ID 用于「追加面板」截图
|
||||||
|
$custID = ''
|
||||||
|
$subID = ''
|
||||||
|
$r = Invoke-WebRequest -Uri "$Base/customers?q=7001" -WebSession $session -UseBasicParsing
|
||||||
|
if ($r.Content -match '/customers/(\d+)') {
|
||||||
|
$custID = $matches[1]
|
||||||
|
$r2 = Invoke-WebRequest -Uri "$Base/customers/$custID" -WebSession $session -UseBasicParsing
|
||||||
|
if ($r2.Content -match 'id="sub-(\d+)"') { $subID = $matches[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) 启动 Chrome
|
||||||
|
$profile = Join-Path $root ('.chrome-profile-' + (Get-Date).ToString('HHmmss'))
|
||||||
|
$chromeArgs = @(
|
||||||
|
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-sandbox',
|
||||||
|
'--no-first-run', '--no-default-browser-check', '--disable-extensions',
|
||||||
|
'--disable-crash-reporter', '--disable-breakpad',
|
||||||
|
'--force-device-scale-factor=1', '--remote-debugging-port=9222',
|
||||||
|
"--user-data-dir=$profile", '--window-size=1440,1000', 'about:blank'
|
||||||
|
)
|
||||||
|
$chromeProc = Start-Process -FilePath $chrome -ArgumentList $chromeArgs -PassThru
|
||||||
|
$targets = $null
|
||||||
|
for ($i = 0; $i -lt 20; $i++) {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
try {
|
||||||
|
$targets = Invoke-RestMethod 'http://127.0.0.1:9222/json'
|
||||||
|
if ($targets) { break }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
if (-not $targets) {
|
||||||
|
Stop-Process -Id $chromeProc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
throw 'Chrome 调试端口未就绪'
|
||||||
|
}
|
||||||
|
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
|
||||||
|
if (-not $page) { throw '拿不到调试目标' }
|
||||||
|
|
||||||
|
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
|
||||||
|
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
|
||||||
|
$script:msgId = 0
|
||||||
|
|
||||||
|
function Send-CDP {
|
||||||
|
param([string]$Method, $Params)
|
||||||
|
$script:msgId++
|
||||||
|
$payload = @{ id = $script:msgId; method = $Method; params = $Params } | ConvertTo-Json -Depth 12 -Compress
|
||||||
|
$bytes = [Text.Encoding]::UTF8.GetBytes($payload)
|
||||||
|
$seg = New-Object 'System.ArraySegment[byte]' -ArgumentList @(, $bytes)
|
||||||
|
$script:ws.SendAsync($seg, [Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
|
||||||
|
while ($true) {
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
do {
|
||||||
|
$buf = New-Object byte[] 131072
|
||||||
|
$segR = New-Object 'System.ArraySegment[byte]' -ArgumentList @(, $buf)
|
||||||
|
$res = $script:ws.ReceiveAsync($segR, [Threading.CancellationToken]::None).Result
|
||||||
|
$ms.Write($buf, 0, $res.Count)
|
||||||
|
} while (-not $res.EndOfMessage)
|
||||||
|
$text = [Text.Encoding]::UTF8.GetString($ms.ToArray())
|
||||||
|
$msg = $text | ConvertFrom-Json
|
||||||
|
if ($msg.id -eq $script:msgId) { return $msg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shoot {
|
||||||
|
param([string]$Name, [string]$Path, [int]$Width = 1440, [int]$Height = 1000, [bool]$SetCookie = $true)
|
||||||
|
Send-CDP 'Emulation.setDeviceMetricsOverride' @{
|
||||||
|
width = $Width; height = $Height; deviceScaleFactor = 1; mobile = ($Width -lt 600)
|
||||||
|
} | Out-Null
|
||||||
|
if ($SetCookie) {
|
||||||
|
Send-CDP 'Network.setCookie' @{
|
||||||
|
name = 'daili_sid'; value = $token; domain = '127.0.0.1'; path = '/'; httpOnly = $true
|
||||||
|
} | Out-Null
|
||||||
|
}
|
||||||
|
Send-CDP 'Page.navigate' @{ url = "$Base$Path" } | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 900
|
||||||
|
$shot = Send-CDP 'Page.captureScreenshot' @{ format = 'png'; captureBeyondViewport = $false }
|
||||||
|
$file = Join-Path $OutDir "$Name.png"
|
||||||
|
[System.IO.File]::WriteAllBytes($file, [Convert]::FromBase64String($shot.result.data))
|
||||||
|
Write-Host (" shot {0,-16} <- {1}{2}" -f $Name, $Base, $Path) -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
Send-CDP 'Network.enable' @{} | Out-Null
|
||||||
|
Send-CDP 'Page.enable' @{} | Out-Null
|
||||||
|
|
||||||
|
Shoot 'login' '/login' 1440 900 $false
|
||||||
|
Shoot 'calendar' '/calendar' 1440 1000
|
||||||
|
Shoot 'customers' '/customers' 1440 900
|
||||||
|
if ($custID) { Shoot 'customer-detail' "/customers/$custID" 1440 1100 }
|
||||||
|
if ($custID -and $subID) {
|
||||||
|
$today = (Get-Date).ToString('yyyy-MM-dd')
|
||||||
|
Shoot 'extend-panel' ("/customers/{0}?extend={1}#extend" -f $custID, $subID) 1440 1200
|
||||||
|
}
|
||||||
|
Shoot 'subscriptions' '/subscriptions?state=all' 1440 900
|
||||||
|
Shoot 'packages' '/packages' 1440 800
|
||||||
|
Shoot 'package-form' '/packages/new' 1200 940
|
||||||
|
Shoot 'customer-form' '/customers/new' 1000 900
|
||||||
|
Shoot 'settings' '/settings' 1440 1000
|
||||||
|
Shoot 'screen' '/screen' 1600 900
|
||||||
|
Shoot 'calendar-mobile' '/calendar' 390 844
|
||||||
|
|
||||||
|
$script:ws.Dispose()
|
||||||
|
Stop-Process -Id $chromeProc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Host '截图完成' -ForegroundColor Green
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# 端到端冒烟测试:真实 HTTP 走一遍主要流程
|
||||||
|
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\smoke.ps1 -Base http://127.0.0.1:18080
|
||||||
|
param(
|
||||||
|
[string]$Base = 'http://127.0.0.1:18080',
|
||||||
|
[string]$User = 'admin',
|
||||||
|
[string]$Pass = 'daili123'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$script:fail = 0
|
||||||
|
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
||||||
|
|
||||||
|
function Send {
|
||||||
|
param([string]$Method, [string]$Path, $Body)
|
||||||
|
$p = @{
|
||||||
|
Uri = "$Base$Path"
|
||||||
|
WebSession = $session
|
||||||
|
UseBasicParsing = $true
|
||||||
|
MaximumRedirection = 5
|
||||||
|
Method = $Method
|
||||||
|
}
|
||||||
|
if ($null -ne $Body) { $p.Body = $Body }
|
||||||
|
try {
|
||||||
|
return Invoke-WebRequest @p
|
||||||
|
} catch [System.Net.WebException] {
|
||||||
|
$resp = $_.Exception.Response
|
||||||
|
if ($null -eq $resp) { throw }
|
||||||
|
$sr = New-Object System.IO.StreamReader($resp.GetResponseStream())
|
||||||
|
$body = $sr.ReadToEnd()
|
||||||
|
$sr.Close()
|
||||||
|
return [pscustomobject]@{
|
||||||
|
StatusCode = [int]$resp.StatusCode
|
||||||
|
Content = $body
|
||||||
|
BaseResponse = [pscustomobject]@{ ResponseUri = $resp.ResponseUri }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Check {
|
||||||
|
param([bool]$Cond, [string]$Msg)
|
||||||
|
if ($Cond) {
|
||||||
|
Write-Host (" [ok] " + $Msg) -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host (" [FAIL] " + $Msg) -ForegroundColor Red
|
||||||
|
$script:fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Final-Path($resp) {
|
||||||
|
if ($resp.PSObject.Properties.Name -contains 'BaseResponse' -and $resp.BaseResponse -and $resp.BaseResponse.ResponseUri) {
|
||||||
|
return $resp.BaseResponse.ResponseUri.AbsolutePath
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = Get-Date
|
||||||
|
$today = $now.ToString('yyyy-MM-dd')
|
||||||
|
$tail = Get-Random -Minimum 1000 -Maximum 9999
|
||||||
|
Write-Host "`n=== 代打台冒烟测试 @ $Base ($today / 尾号 $tail) ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. 初始化管理员(或登录)
|
||||||
|
$r = Send 'GET' '/setup'
|
||||||
|
if ($r.Content -match '创建第一个管理员') {
|
||||||
|
$r = Send 'POST' '/setup' @{ username = $User; display_name = '老板'; password = $Pass; password2 = $Pass }
|
||||||
|
Check ($r.StatusCode -eq 200) '创建管理员并自动登录'
|
||||||
|
Check ((Final-Path $r) -like '/packages*') '初始化后跳转到套餐页'
|
||||||
|
} else {
|
||||||
|
$r = Send 'POST' '/login' @{ username = $User; password = $Pass; next = '/calendar' }
|
||||||
|
Check ($r.StatusCode -eq 200 -and (Final-Path $r) -like '/calendar*') '已存在管理员,登录成功'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 套餐
|
||||||
|
$r = Send 'GET' '/packages'
|
||||||
|
Check ($r.StatusCode -eq 200) '套餐页可访问'
|
||||||
|
$colors = @('#2F5D50', '#6B4E9B')
|
||||||
|
$pkgs = @(
|
||||||
|
@{ name = '包月日常'; unit = 'day'; default_amount = 30; price = 300; note = '每天日常 + 周常' },
|
||||||
|
@{ name = '深渊十次'; unit = 'count'; default_amount = 10; price = 180; note = '按次结算' }
|
||||||
|
)
|
||||||
|
for ($i = 0; $i -lt $pkgs.Count; $i++) {
|
||||||
|
$p = $pkgs[$i]
|
||||||
|
$r = Send 'POST' '/packages' @{
|
||||||
|
name = $p.name; unit = $p.unit; default_amount = "$($p.default_amount)"
|
||||||
|
price = "$($p.price)"; color = $colors[$i]; note = $p.note; sort_order = "$($i + 1)"
|
||||||
|
}
|
||||||
|
Check ($r.Content -match '套餐已保存|套餐已创建|套餐') "提交套餐 $($p.name)"
|
||||||
|
}
|
||||||
|
$r = Send 'GET' '/packages'
|
||||||
|
Check ($r.Content -match '包月日常' -and $r.Content -match '深渊十次') '套餐列表包含两个套餐'
|
||||||
|
$r = Send 'GET' '/packages/1/edit'
|
||||||
|
Check ($r.StatusCode -eq 200 -and $r.Content -match '包月日常') '套餐编辑页可打开'
|
||||||
|
|
||||||
|
# 3. 客户
|
||||||
|
$r = Send 'POST' '/customers' @{
|
||||||
|
tail = "$tail"; name = '老张'; game_name = '王者荣耀'; game_server = '微信区'
|
||||||
|
contact = 'wx: laozhang'; tags = '老客户,包月'; source = '抖音'; note = '喜欢晚上打'
|
||||||
|
}
|
||||||
|
$custPath = Final-Path $r
|
||||||
|
Check ($custPath -like '/customers/*') "新建客户成功 -> $custPath"
|
||||||
|
$custID = ($custPath -split '/')[-1]
|
||||||
|
|
||||||
|
$r = Send 'GET' "/customers?q=$tail"
|
||||||
|
Check ($r.Content -match '老张') "尾号 $tail 搜索命中老张"
|
||||||
|
|
||||||
|
$r = Send 'POST' '/customers' @{ tail = "$tail"; name = '老张' }
|
||||||
|
Check ($r.StatusCode -eq 400 -and $r.Content -match '已存在') '重复客户被拒绝并提示'
|
||||||
|
|
||||||
|
# 4. 订阅(按日 + 按次)
|
||||||
|
$r = Send 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30'; note = '首单' }
|
||||||
|
Check ($r.Content -match '已开通') '开通按日订阅'
|
||||||
|
$r = Send 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
|
||||||
|
Check ($r.Content -match '追加时长') '同一套餐重复订阅被拦截'
|
||||||
|
|
||||||
|
$r = Send 'POST' "/customers/$custID/subscriptions" @{
|
||||||
|
package_id = '2'; start_date = $today; total_count = '10'; end_date = $now.AddDays(10).ToString('yyyy-MM-dd')
|
||||||
|
}
|
||||||
|
Check ($r.Content -match '已开通') '开通按次订阅'
|
||||||
|
|
||||||
|
$r = Send 'GET' "/customers/$custID"
|
||||||
|
$subIDs = [regex]::Matches($r.Content, 'id="sub-(\d+)"') | ForEach-Object { [int]$_.Groups[1].Value }
|
||||||
|
Check ($subIDs.Count -ge 2) "客户页出现 2 张订阅卡(实际 $($subIDs.Count))"
|
||||||
|
$daySub = $subIDs[0]
|
||||||
|
$cntSub = $subIDs[1]
|
||||||
|
|
||||||
|
# 5. 追加时长(日历手动选终止日:今天 +45 天)
|
||||||
|
$newEnd = $now.AddDays(45)
|
||||||
|
$oldEnd = $now.AddDays(29)
|
||||||
|
$expectDays = ($newEnd.Date - $oldEnd.Date).Days
|
||||||
|
$r = Send 'POST' "/subscriptions/$daySub/extend" @{
|
||||||
|
new_end_date = $newEnd.ToString('yyyy-MM-dd'); note = '微信转账续费'; back = "/customers/$custID"
|
||||||
|
}
|
||||||
|
Check ($r.Content -match "已追加 $expectDays 天") "追加时长反算正确(期望 $expectDays 天)"
|
||||||
|
|
||||||
|
$r = Send 'GET' "/customers/$custID"
|
||||||
|
Check ($r.Content -match '剩 46 天') '客户页剩余天数更新为 46 天'
|
||||||
|
Check ($r.Content -match '微信转账续费') '追加记录已留痕(备注)'
|
||||||
|
Check ($r.Content -match '追加天数') '追加记录标注类型'
|
||||||
|
|
||||||
|
# 6. 日历
|
||||||
|
$r = Send 'GET' "/calendar?y=$($now.Year)&m=$($now.Month)&d=$today"
|
||||||
|
Check ($r.StatusCode -eq 200) '日历页可打开'
|
||||||
|
Check ($r.Content -match '老张') '日历当天出现该客户色签'
|
||||||
|
Check ($r.Content -match '当日待打') '日历抽屉存在'
|
||||||
|
|
||||||
|
# 7. 打卡(按日)
|
||||||
|
$r = Send 'POST' "/subscriptions/$daySub/checkin" @{ date = $today; status = 'done'; note = '打完'; back = "/calendar?d=$today" }
|
||||||
|
Check ($r.Content -match '已记录') '按日打卡成功'
|
||||||
|
|
||||||
|
# 8. 按次打卡扣次
|
||||||
|
$r = Send 'POST' "/subscriptions/$cntSub/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
|
Check ($r.Content -match '已记录') '按次打卡成功(应扣 1 次)'
|
||||||
|
$r = Send 'GET' "/customers/$custID"
|
||||||
|
Check ($r.Content -match '剩 9 / 10 次') '按次剩余次数扣减为 9'
|
||||||
|
|
||||||
|
# 9. 暂停 / 恢复
|
||||||
|
$r = Send 'POST' "/subscriptions/$daySub/pause" @{ back = "/customers/$custID" }
|
||||||
|
Check ($r.Content -match '已暂停') '暂停订阅'
|
||||||
|
$r = Send 'GET' "/customers/$custID"
|
||||||
|
Check ($r.Content -match '暂停中') '客户页显示暂停中'
|
||||||
|
$r = Send 'POST' "/subscriptions/$daySub/resume" @{ back = "/customers/$custID" }
|
||||||
|
Check ($r.Content -match '已恢复') '恢复订阅'
|
||||||
|
|
||||||
|
# 9.5 无有效期的按次订阅:靠客户页「今日打卡」扣次
|
||||||
|
$r = Send 'POST' '/packages' @{
|
||||||
|
name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3'
|
||||||
|
}
|
||||||
|
Check ($r.Content -match '套餐') '新建第三个套餐(按次、无有效期)'
|
||||||
|
$r = Send 'GET' '/packages'
|
||||||
|
$pkgIDs = [regex]::Matches($r.Content, '/packages/(\d+)/edit') | ForEach-Object { [int]$_.Groups[1].Value }
|
||||||
|
$newPkg = ($pkgIDs | Measure-Object -Maximum).Maximum
|
||||||
|
$r = Send 'POST' "/customers/$custID/subscriptions" @{ package_id = "$newPkg"; start_date = $today; total_count = '5' }
|
||||||
|
Check ($r.Content -match '已开通') '开通无有效期的按次订阅'
|
||||||
|
|
||||||
|
$r = Send 'GET' "/customers/$custID"
|
||||||
|
Check ($r.Content -match '今日打卡') '订阅卡片出现「今日打卡」按钮'
|
||||||
|
$ids3 = [regex]::Matches($r.Content, 'id="sub-(\d+)"') | ForEach-Object { [int]$_.Groups[1].Value }
|
||||||
|
$thirdSub = $ids3[-1]
|
||||||
|
$r = Send 'POST' "/subscriptions/$thirdSub/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
|
Check ($r.Content -match '已记录') '无有效期按次订阅打卡成功'
|
||||||
|
$r = Send 'GET' "/customers/$custID"
|
||||||
|
Check ($r.Content -match '剩 4 / 5 次') '打卡后次数扣减为 4'
|
||||||
|
|
||||||
|
# 10. 大屏
|
||||||
|
$r = Send 'GET' '/screen'
|
||||||
|
Check ($r.StatusCode -eq 200) '大屏可访问'
|
||||||
|
Check ($r.Content -match '即将到期 TOP 10') '大屏含 TOP 榜'
|
||||||
|
Check ($r.Content -match '到期热力') '大屏含热力月历'
|
||||||
|
Check ($r.Content -match "$tail") '大屏显示该客户尾号'
|
||||||
|
|
||||||
|
# 11. 日志 / 设置 / 导出
|
||||||
|
$r = Send 'GET' '/logs'
|
||||||
|
Check ($r.Content -match '追加时长') '操作日志含追加记录'
|
||||||
|
$r = Send 'GET' '/settings'
|
||||||
|
Check ($r.StatusCode -eq 200 -and $r.Content -match '基础设置') '设置页可打开'
|
||||||
|
$r = Send 'GET' '/export/customers.csv'
|
||||||
|
Check ($r.StatusCode -eq 200 -and $r.Content -match '手机尾号') '客户 CSV 导出正常'
|
||||||
|
$r = Send 'GET' '/export/subscriptions.csv'
|
||||||
|
Check ($r.StatusCode -eq 200 -and $r.Content -match '剩余') '订阅 CSV 导出正常'
|
||||||
|
$r = Send 'GET' '/export/logs.csv'
|
||||||
|
Check ($r.StatusCode -eq 200 -and $r.Content -match '操作人') '日志 CSV 导出正常'
|
||||||
|
|
||||||
|
# 12. 静态资源与健康检查
|
||||||
|
$r = Send 'GET' '/static/app.css'
|
||||||
|
Check ($r.StatusCode -eq 200 -and $r.Content -match 'ticket') '静态样式可访问'
|
||||||
|
$r = Send 'GET' '/healthz'
|
||||||
|
Check ($r.Content -match 'ok') '健康检查正常'
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if ($script:fail -eq 0) {
|
||||||
|
Write-Host "全部通过" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
Write-Host ("失败 $script:fail 项") -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""校验备份文件可被标准 SQLite 打开并含数据(Windows 自带 Python 或 Miniconda 均可运行)。"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
pattern = os.path.join(sys.argv[1], "*.db")
|
||||||
|
files = sorted(glob.glob(pattern), key=os.path.getmtime)
|
||||||
|
if not files:
|
||||||
|
print("FAIL: 没有找到备份文件")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
path = files[-1]
|
||||||
|
print("备份文件:", os.path.basename(path), os.path.getsize(path), "字节")
|
||||||
|
con = sqlite3.connect(path)
|
||||||
|
tables = [r[0] for r in con.execute(
|
||||||
|
"select name from sqlite_master where type='table' order by name")]
|
||||||
|
print("表:", ", ".join(tables))
|
||||||
|
for t in ("users", "packages", "customers", "subscriptions", "sub_adjustments", "checkins", "op_logs"):
|
||||||
|
if t in tables:
|
||||||
|
n = con.execute("select count(*) from %s" % t).fetchone()[0]
|
||||||
|
print(" %-16s %d 行" % (t, n))
|
||||||
|
con.close()
|
||||||
|
print("OK")
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
module modernc.org/fileutil
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
Reference in New Issue
Block a user