比较提交
1
次代码提交
3751dffef9
...
v1.5.0
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
f36149efc3 |
+6
@@ -66,6 +66,12 @@ venv/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ---- AI 助手的工作记忆 ----
|
||||
# .codebuddy/ 里是会话记忆与技能缓存,属于「开发过程产物」而不是项目资产,
|
||||
# 与代码版本无关。对外开源后也不该混进仓库(README 里不需要它,
|
||||
# 而 tools/check_docs.py 会把其中的 .md 当成项目文档一起扫)。
|
||||
.codebuddy/
|
||||
|
||||
# ---- 部署本地配置(含明文密码,只提交 .env.example)----
|
||||
.env
|
||||
.env.local
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ FROM python:3.13-slim AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="WorkBuddy Portal" \
|
||||
org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \
|
||||
org.opencontainers.image.version="1.4.0" \
|
||||
org.opencontainers.image.version="1.5.0" \
|
||||
org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal"
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单 |
|
||||
| 备份 | **自动 + 手动**一致性快照、按份数清理、在线下载、**一键恢复**(恢复前自动再存一份);用户可导出本人全部数据 |
|
||||
| 资源上限 | 容器层 `cpus` / `mem_limit` / `pids_limit`;实例层采集最小间隔、采集跨度硬顶 **31 天**、**有任务在跑不允许新建任务** |
|
||||
| 版本 | v1.4.0 |
|
||||
| 版本 | v1.5.0 |
|
||||
| **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) |
|
||||
|
||||
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
|
||||
@@ -140,7 +140,8 @@ docker compose up -d
|
||||
> v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密);
|
||||
> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级);
|
||||
> v1.3.0 → v1.4.0 只加一列(`users.session_ver`)与一张新表(`backups`),
|
||||
> **无数据搬运**。三次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
|
||||
> **无数据搬运**;v1.4.0 → v1.5.0 **没有库结构变更**(纯界面与安全加固)。
|
||||
> 四次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
|
||||
|
||||
### 第一次使用必做
|
||||
|
||||
|
||||
+46
-4
@@ -3,15 +3,34 @@
|
||||
## 支持范围
|
||||
|
||||
本项目按「自托管」定位开发,**1.4.0 起已按「可以暴露到公网」加固**(IP 来源、限速、
|
||||
资源上限、采集跨度硬顶都补上了),但仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。
|
||||
资源上限、采集跨度硬顶都补上了),**1.5.0 又按同一前提收口了一轮信息泄漏、注入与读接口滥用**。
|
||||
即便如此,仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。
|
||||
|
||||
| 版本 | 是否接受安全修复 |
|
||||
|---|---|
|
||||
| `1.4.x`(当前) | ✅ |
|
||||
| `1.5.x`(当前) | ✅ |
|
||||
| `1.4.x` | ⚠️ 可用,但建议升级(1.5.0 收口了内部异常/表名外泄、响应头注入、导出路径穿越、读接口无刹车) |
|
||||
| `1.3.x` | ⚠️ 可用,但**公网暴露前必须升级**(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题) |
|
||||
| `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) |
|
||||
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) |
|
||||
|
||||
> **1.5.0 收口了什么**(都不是「可直接利用的高危漏洞」,而是**对外时迟早出事**的那一类):
|
||||
>
|
||||
> 1. **信息泄漏**:接口的兜底异常原本把 `str(e)` 原样回给客户端(含绝对路径与 SQL 片段),
|
||||
> 现在只回一个 8 位事件号,细节进服务端日志;`/api/manifest` / `/api/bundle` 里的
|
||||
> **表名与库文件路径**也只对管理员下发。另外补掉了登录接口的**用户名枚举时序侧信道**
|
||||
> (账号不存在时也走一次同代价的哑哈希)。
|
||||
> 2. **注入**:导出文件名含用户名,而用户名并不总是注册正则的产物
|
||||
> (`manage.py passwd` 建号、老库升级上来的名字都可能带引号或 CR/LF)——
|
||||
> 直接拼 `Content-Disposition` 就是**响应头注入**;`export_csv` 的账号名直拼路径则是
|
||||
> **路径穿越**。两者都改为先收敛字符集,建号也补上了用户名校验。
|
||||
> 3. **资源滥用**:读接口原本**没有任何刹车**,一个注册账号循环调 `/api/bundle`
|
||||
> 就能持续吃满 CPU 与出口带宽。现在 `/api/*` 按账号(未登录按 IP)限速 240 次 / 60 秒。
|
||||
> 4. **隐私落盘**:进程 `umask` 收到 `0077` —— 数据目录里是**对话正文**、凭证密文、主密钥
|
||||
> 与它们的明文导出副本,默认 `0644` 等于同机任何用户可读。
|
||||
> 5. **调试器**:`--debug` 不再允许绑定对外地址(Werkzeug 调试器 = 任意代码执行,
|
||||
> 而 `--host` 的默认值恰好是 `0.0.0.0`)。
|
||||
|
||||
> **1.4.0 修掉了什么**(三条 P0):
|
||||
> 1. **`X-Forwarded-For` 可伪造 ⇒ 三道 IP 防线(验证码限速 / 注册配额 / 登录锁定)同时失效**。
|
||||
> 实测:每次换一个伪造的 XFF,45 次验证码请求**全部放行**。现在默认不信任 XFF,
|
||||
@@ -76,7 +95,8 @@
|
||||
| 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` |
|
||||
| 失败限速(双维度、**强度刻意不同**) | **IP 维度**:连续失败 5 次**真锁** 10 分钟。**用户名维度**:只做秒级递增退避(封顶 60 秒)—— 「知道用户名就能把对方锁死 10 分钟」本身是攻击,而**管理员用户名在导航里是公开的**。另有**单来源登录尝试总量** 40 次 / 5 分钟(**含成功**),挡「慢慢撞、不触发失败阈值」。计数表有上限与 TTL | `security.auth_block_reason` / `note_try` / `try_window_left` |
|
||||
| 失败提示不泄漏信息 | 提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时 | `web/views.py:login` |
|
||||
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
|
||||
| **用户名枚举的时序侧信道** | 口令校验对**不存在的账号**也走一次同代价的哑哈希(`_DUMMY_HASH`)。否则「账号不存在」比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在 | `security.login_ok` / `_dummy_verify` |
|
||||
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP、**`Permissions-Policy`(显式关掉地理位置/麦克风/摄像头/支付/USB)**;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
|
||||
| **HSTS 只在真 HTTPS 时下发** | `COOKIE_SECURE or FORCE_HTTPS` 才发 `Strict-Transport-Security`。纯 HTTP 部署下发会让浏览器强升 https,表现成白屏 —— 很难归因的一类故障 | `security.apply_security_headers` |
|
||||
| **访问日志** | waitress 自己不记 access log;本程序补上(跳过 `/static/` 与 `/captcha.png`),写进 `logs/app.log`,`WB_ACCESS_LOG` 控制 | `security._access_log` |
|
||||
|
||||
@@ -119,6 +139,19 @@
|
||||
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。**来源按 `client_ip()` 判定**,伪造 XFF 无效 | `security.captcha_fetch_allowed` |
|
||||
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭。**同样按 `client_ip()` 判定** | `security.register_quota` |
|
||||
|
||||
### 信息泄漏收敛(v1.5.0)
|
||||
|
||||
不致命,但都是**踩点阶段最好用**的材料,所以统一收掉。
|
||||
|
||||
| 项 | 做法 | 位置 |
|
||||
|---|---|---|
|
||||
| **内部异常不外泄** | 接口的兜底 `except Exception` 不再回 `str(e)`(那会带绝对路径、SQL、`sqlite3` 报错),改为**完整堆栈进服务端日志 + 一个 8 位事件号**。面向用户写的业务异常(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError`)不受影响,它们本来就是给用户看的 | `web/api.py::_internal` |
|
||||
| 内部实现清单只给管理员 | `/api/manifest`、`/api/bundle` 的 `sources`(表名)与 `archive`(库文件路径)对普通账号为空;大屏页「数据源」卡片随之收起 | `query.manifest(sources=…)` |
|
||||
| **响应头注入收口** | 导出文件名一律先收敛成 ASCII 安全名(只留 `[A-Za-z0-9._-]`、打平 `..`),再按 RFC 5987 附上原名。含引号 / CR / LF 的用户名不可能再拼出畸形响应头 | `security.safe_filename` / `content_disposition` |
|
||||
| **导出路径穿越收口** | `export_csv` 的账号名 tag 同样收敛,且结果固定落在 `EXPORT_DIR` 之下。根因也堵了:`manage.py passwd` **建号时补上用户名校验**(与注册页同一套) | `collect.export_csv`、`manage.py` |
|
||||
| 上传体量反馈一致 | `413` 返回 JSON(与其它接口错误同形状)。不加这一层时 Flask 吐 HTML 页,前端 `r.json()` 会炸成「Unexpected token <」 | `workbuddy_portal/__init__.py` |
|
||||
| **访问日志不带查询串** | `_access_log` 记的是 `request.path` —— 若记 `?q=<搜索词>`,prompt 片段会顺着搜索词进日志文件 | `security._access_log` |
|
||||
|
||||
### 资源与频率限制(对外提供服务时)
|
||||
|
||||
单写者架构下**不加副本扛负载**,所以限制必须落在「单实例资源」与「单账号频率」两处。
|
||||
@@ -129,6 +162,7 @@
|
||||
| **采集跨度硬顶** | `COLLECT_MAX_RANGE_DAYS_HARD = 31`(= 1 个月)。`collect_max_range_days` 只是更严的旋钮,**改大也突破不了** —— 上限由代码兜底,不依赖写入校验。历史脏数据、手工改库都过不去 | `config.COLLECT_MAX_RANGE_DAYS_HARD`、`collect.max_range_days` |
|
||||
| 调度时刻数硬顶 | `SCHEDULE_SLOTS_HARD_MAX = 12`,另有可配置的更严上限 `max_schedule_slots_per_day`(默认 6)。时刻数量直接决定采集频次,是「一个账号能不能把云端打满」的开关 | `config.normalize_setting` |
|
||||
| 重操作最小间隔 | 采集 60s / CSV 导出 15s / **导出本人数据 10s** / `vacuum` 120s / 补全 prompt 60s / 重算 5s;超限 `429` 并给出剩余等待秒数 | `security.action_allowed` |
|
||||
| **读接口限速** | `/api/*` 按「账号」计数(未登录退化成来源 IP,**不做 DB 回查**)限速 **240 次 / 60 秒**,超限 `429`。重点挡的是 `/api/bundle` —— 它要算全量逐日聚合、还会下发最多 2 万条明细,把它放进 `for` 循环就能持续吃掉 CPU 与出口带宽(这不需要任何漏洞)。阈值刻意宽松:大屏切一次筛选只发 1~2 个请求 | `security.api_rate_ok` |
|
||||
| 容器资源上限 | `cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` **与 `mem_limit` 相等**(= 禁用 swap:超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/ `pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库) | `docker-compose.yml` |
|
||||
| 线程数受控 | `WB_THREADS`(容器默认 4,代码默认 8)决定单实例能同时吃进几个慢请求;1 核配 8 线程容易出现「都在等 CPU」的假并发 | `config.THREADS` |
|
||||
| SSRF 收口 | `api_base` 拒绝云元数据地址(`169.254.169.254` / `metadata.google.internal` / `[fd00:ec2::254]`)—— SSRF 拿云上临时凭证最经典的一跳,且无任何合法采集场景需要它 | `config.normalize_setting` |
|
||||
@@ -152,7 +186,9 @@
|
||||
|---|---|---|
|
||||
| 容器权限 | 运行层非 root(uid/gid 1000 `app`);`init: true` 让 tini 接管 PID 1,`docker stop` 能干净传到 python | `Dockerfile`、`docker-compose.yml` |
|
||||
| **密钥文件权限** | `data/instance.json` 在 POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,同机其他用户可读)。恢复时写回也走同一处理,并先把原文件另存 `.pre-restore-<ts>` | `config._instance_init`、`backup._restore_instance` |
|
||||
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` |
|
||||
| **进程级落盘权限(v1.5.0)** | 启动时把 `umask` 收到 `0077`,此后**本进程新建的一切文件**天然是 owner-only:SQLite 的 `-wal` / `-shm`、导出 CSV、备份 zip、日志一并覆盖。刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,而漏掉的那个文件里往往正好是最新的**对话正文**。配套 `harden_dir()` / `harden_file()` 收紧遗留的 0755 目录与已存在的文件 | `config.harden_process` / `harden_dir` / `harden_file` |
|
||||
| **调试器不绑对外地址** | `manage.py serve --debug` 在 `--host` 非回环时**直接拒绝启动**(退出码 2)。Werkzeug 的交互式调试器等于任意代码执行,而 `--host` 默认就是 `0.0.0.0` —— 这条把「本机调试习惯」和「公网部署」之间的那个坑堵死 | `manage.py cmd_serve` |
|
||||
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB`(超限由 `413` 处理器转成 JSON) | `workbuddy_portal/__init__.py` |
|
||||
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
|
||||
|
||||
**绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、
|
||||
@@ -222,3 +258,9 @@
|
||||
「已保存但无法解密」
|
||||
- [ ] 升级到 1.4.0 后确认「任务管理」里调度时刻**对普通账号是只读的**,
|
||||
且访问 `/backups` 返回 403(用普通账号各试一次)
|
||||
- [ ] **升级到 1.5.0 后跑一次 `python tools/smoke.py`**:第 9 节专门验「对外暴露面」
|
||||
(响应头收敛 / 异常不外泄 / 读接口限速 / 安全响应头 / 普通账号拿不到数据源清单)
|
||||
- [ ] 确认容器里进程的落盘权限已收紧:`docker compose exec portal sh -c 'umask'` 应为 `0077`,
|
||||
且 `ls -l /app/data` 的目录权限是 `drwx------`
|
||||
- [ ] 确认从外部看到的响应头里有 `Permissions-Policy`(1.5.0 新增)
|
||||
- [ ] 确认 `--debug` 没有被用在对外地址上:`python manage.py serve --debug` 应当**直接报错退出**
|
||||
|
||||
+110
@@ -8,6 +8,116 @@
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] — 2026-09-18
|
||||
|
||||
**主题:按「将会被公网访问」重新审一遍界面与暴露面**
|
||||
|
||||
两件事:① 清掉界面里的 AI 生成残留与「AI 味」,把视觉从「霓虹渐变」改回工程控制台;
|
||||
② 按「能被公网访问」的前提逐条收口信息泄漏、注入与资源滥用。
|
||||
**没有库结构变更**(`user_version` 仍是 4),升级不需要手工介入。
|
||||
|
||||
### 安全与隐私加固(对外提供服务)
|
||||
|
||||
**主题:把「能被公网访问」当成前提,逐条收口信息泄漏与资源滥用**
|
||||
|
||||
这一轮没有新增功能,全部是收口。原则:**能把信息少给一点就少给一点,
|
||||
能把权限多收一层就多收一层**。
|
||||
|
||||
**信息泄漏(这类问题的共同点是:平时看不出问题,踩点阶段最好用)**
|
||||
|
||||
- **内部异常不再直出**。`/api/collect`、`/api/maintenance/*`、`/api/backups*` 的兜底
|
||||
`except Exception` 原本把 `str(e)` 原样回给客户端 —— 那里面会带绝对路径
|
||||
(`/app/workbuddy_portal/...`)、SQL 语句、`sqlite3` 的报错。现在统一走
|
||||
`web/api.py::_internal`:完整堆栈写进服务端日志并打一个 **8 位事件号**,
|
||||
客户端只拿到事件号。既不影响「出事了去查日志」,也不送材料。
|
||||
(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError` 这些**面向用户写的**
|
||||
业务异常不受影响,它们的文案本来就是给用户看的。)
|
||||
- **普通账号拿不到内部实现清单**。`/api/manifest`、`/api/bundle` 里的 `sources`
|
||||
(表名 `usage_records`、`daily 聚合视图` …)与 `archive`(`data/usage.sqlite`)
|
||||
只对管理员下发;非管理员的大屏页「数据源」卡片自动收起,避免留一个只有表头的空壳。
|
||||
- **响应头注入**。导出文件名里含用户名,而用户名**并不总是**注册正则的产物
|
||||
(`manage.py passwd` 建号时不校验、老库升级上来的名字也可能带引号或 CR/LF)。
|
||||
一旦名字里有引号或换行,直接拼 `Content-Disposition` 就是响应拆分。
|
||||
新增 `security.safe_filename()` 与 `security.content_disposition()`:
|
||||
收敛成 ASCII 安全名 + 按 RFC 5987 附上原名,`/records/export` 与
|
||||
`/profile/export` 都改走它。
|
||||
- **路径穿越**。`collect.export_csv()` 的账号名 tag 直接拼进文件路径 ——
|
||||
一个叫 `..\..\x` 的账号能把 CSV 写到数据目录之外。现在只保留 `[A-Za-z0-9._-]`,
|
||||
且结果固定落在 `EXPORT_DIR` 之下。同时 `manage.py passwd` **建号时补上用户名校验**
|
||||
(与注册页同一套),从根上不再产生这类名字。
|
||||
- **用户名枚举的时序侧信道**。`login_ok` 在账号不存在时不执行任何哈希计算,
|
||||
比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在。
|
||||
现在对不存在的账号也走一次**同代价的哑哈希**,两条路径耗时对齐。
|
||||
|
||||
**隐私:落盘权限(这条是对外部署最容易被忽略的)**
|
||||
|
||||
- 数据目录里躺着的是:`usage_records.prompt`(**用户与 AI 的完整对话正文**)、
|
||||
凭证密文、`instance.json`(主密钥)、`exports/*.csv`(对话正文的**明文**副本)、
|
||||
`backups/*.zip`(全库 + 主密钥)。默认 `umask 022` 会把它们留成 `0644 / 0755`,
|
||||
也就是**同机任何用户都能读**。
|
||||
- 新增 `config.harden_process()`:在进程启动时把 `umask` 收到 `0077`,
|
||||
此后新建的一切文件(含 SQLite 的 `-wal` / `-shm`)天然是 owner-only。
|
||||
刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,
|
||||
而漏掉的那个文件里往往正好是最新的对话正文。
|
||||
- 配套 `harden_dir()` / `harden_file()` 收紧**已经存在**的目录(老版本留下的 0755)
|
||||
以及导出 CSV、备份 zip 落盘后的权限。Windows 无此模型,函数直接跳过。
|
||||
|
||||
**资源滥用**
|
||||
|
||||
- **读接口限速**:登录有 IP 锁定、注册有配额、采集有最小间隔,但读接口原本**没有任何刹车** ——
|
||||
一个注册账号把 F12 里的 `/api/bundle` 请求放进 `for` 循环就能持续吃 CPU 与出口带宽
|
||||
(那个接口要算全量逐日聚合,还会下发最多 2 万条明细)。现在 `/api/*` 按
|
||||
「账号(未登录时按来源 IP)」限速 240 次 / 60 秒,超出返回 `429`。
|
||||
阈值给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到。
|
||||
|
||||
**其它**
|
||||
|
||||
- **`--debug` 不再能绑到对外地址**。Werkzeug 的交互式调试器等于任意代码执行,
|
||||
而 `--host` 默认就是 `0.0.0.0`;`python manage.py serve --debug` 会直接把 RCE 挂上公网。
|
||||
现在绑非回环地址直接拒绝启动,并提示本机调试的正确写法。
|
||||
- 新增 `Permissions-Policy` 响应头(显式关掉地理位置 / 麦克风 / 摄像头 / 支付 / USB)。
|
||||
- 新增 `413` 处理器:请求体超限时接口返回 JSON 而不是 Flask 默认 HTML 页
|
||||
(前端 `r.json()` 在 HTML 上会炸,表现成「Unexpected token <」)。
|
||||
- 文档:新增 [部署指南第十三节「安全与隐私基线」](DEPLOYMENT.md#十三安全与隐私基线) ——
|
||||
代码层已做到的、公网暴露前必须自己做的、存了哪些个人数据、保留期怎么定、上线自检命令。
|
||||
|
||||
验证:`python tools/smoke.py` **ok=264 / fail=0**(第 9 节专门覆盖上述各项:
|
||||
`safe_filename` 边界、真实导出响应头不含引号/换行、每个 `except Exception` 都走 `_internal`、
|
||||
读接口限速、安全响应头、大屏页已转义、普通账号拿不到数据源清单)。
|
||||
|
||||
### 界面去 AI 化
|
||||
|
||||
**主题:清掉界面里的 AI 生成残留与「AI 味」**
|
||||
|
||||
- **大屏页清理生成器残留**:`static/dashboard/index.html` 里 **114 处**
|
||||
`data-page-node-id="…"`(页面生成工具给每个节点打的标记)全部删除,
|
||||
文件从 64 KB 降到 58 KB。这类属性没有任何运行时作用,只是生成痕迹。
|
||||
- **视觉系统重做**(`static/css/app.css` + 大屏内联样式):由「深色底 + 霓虹渐变 + 辉光」
|
||||
改为中性的工程控制台风格 —— 灰阶打底、单一蓝色强调色。具体删掉了:
|
||||
`radial-gradient` 页面背景、按钮/激活态/分页/徽标的 `linear-gradient`、
|
||||
`box-shadow` 发光(品牌点、主按钮、日历格)、渐变文字(错误页大号状态码)、
|
||||
以及标题前的彩色装饰条(`.pagehead h1::before`、`.card h2::before`、`h1::before`)。
|
||||
- **KPI 改用灰度分层**:原来每张卡一条彩虹色左色条,现在 `--c` 只渲染成一个 8px
|
||||
状态点(`采集健康`/`自动备份` 这类状态卡仍能一眼区分),其余靠灰度与字重。
|
||||
- **图表配色同步降饱和**:ECharts `PALETTE`、日历 5 级色阶、热力图 `visualMap`
|
||||
与后台共用同一套语义色;数据系列改用蓝色阶 + 1 个琥珀色对比项。
|
||||
- **文案精简**:删掉说教式长段落(`配置管理` 页整张「为什么采集参数是只读的」说明卡)、
|
||||
把只给结论即可的提示压成一句话;`任务管理`/`配置管理`/`备份管理`/`用户管理`
|
||||
的长提示同步收敛。
|
||||
- **修掉两个真实缺陷**(都在这次清理中暴露):
|
||||
- `templates/profile.html` 的 Markdown 星号漏进 HTML —— `**任何时候都不回传明文**`
|
||||
会在页面上原样渲染成星号;
|
||||
- 登录页写着「首次部署默认账号 `admin` / `admin123`」,而 1.4.0 起已改用随机口令,
|
||||
该提示会把人引向一个永远登不上的口令,已删除。
|
||||
- **文档同步**:`docs/USER-GUIDE.md` 中引用上述被删文案的两处说明一并更新。
|
||||
界面截图 `docs/images/*.png` 仍是旧观感,需要用 `python tools/shots.py` 重出。
|
||||
|
||||
验证:`python tools/smoke.py` 全绿(ok=233 / fail=0),其中
|
||||
「页面 class 与 app.css 选择器对账」确认新版样式表没有漏掉任何模板在用的类名;
|
||||
`python tools/check_docs.py` 0 处问题。
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] — 2026-09-16
|
||||
|
||||
**主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制**
|
||||
|
||||
+109
-7
@@ -18,6 +18,7 @@
|
||||
- [十、日常巡检](#十日常巡检)
|
||||
- [十一、排错](#十一排错)
|
||||
- [十二、配置项速查](#十二配置项速查)
|
||||
- [十三、安全与隐私基线](#十三安全与隐私基线)
|
||||
|
||||
---
|
||||
|
||||
@@ -437,7 +438,7 @@ name: workbuddy-portal
|
||||
|
||||
services:
|
||||
portal:
|
||||
image: git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
|
||||
image: git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
|
||||
container_name: workbuddy-portal
|
||||
restart: unless-stopped
|
||||
init: true # tini 接管 PID 1,docker stop 能干净传到 python
|
||||
@@ -675,7 +676,7 @@ WB_TRUST_PROXY=1
|
||||
```bash
|
||||
export GITEA_TOKEN=<你的令牌>
|
||||
tools/push-all.sh # 推 main 分支 + 镜像 latest
|
||||
tools/push-all.sh 1.4.0 # 同时打一个版本 tag 并推送
|
||||
tools/push-all.sh 1.5.0 # 同时打一个版本 tag 并推送
|
||||
```
|
||||
|
||||
脚本做的事:
|
||||
@@ -713,15 +714,15 @@ docker login git.iwali.top -u wangchuanli
|
||||
|
||||
docker compose build
|
||||
docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \
|
||||
git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
|
||||
git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
|
||||
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
|
||||
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
|
||||
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
|
||||
```
|
||||
|
||||
### 7.4 验证远端
|
||||
|
||||
```bash
|
||||
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
|
||||
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
|
||||
# 或
|
||||
curl -s -u wangchuanli:TOKEN \
|
||||
https://git.iwali.top/api/v1/packages/wangchuanli?type=container
|
||||
@@ -944,8 +945,9 @@ sudo systemctl restart workbuddy-portal
|
||||
| **1.1.0 → 1.2.0**(单用户 → 多用户) | 0 → 2 | 建 `users` 表并写入首个管理员;`settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键,**老数据整体归到第一个账号**;明文 Cookie **就地加密**;建 `captchas` 表 | 不需要 |
|
||||
| **1.2.0 → 1.3.0**(权限收敛) | 2 → 3 | **配置作用域收敛**:把管理员个人名下的调度与采集参数提升到实例级 `user_id=0`,再清掉个人残留;实例级不再保留 `cookie` / `user_agent` | 不需要 |
|
||||
| **1.3.0 → 1.4.0**(备份 + 对外加固) | 3 → 4 | 只做两件事:`users` 加一列 `session_ver`(非空、默认 0)、建 `backups` 表(备份索引)。**没有数据搬运、没有键位变动** | 不需要 |
|
||||
| **1.4.0 → 1.5.0**(界面改版 + 公网加固) | 4 → 4 | **没有任何库结构变更**,`init_db()` 只做幂等校验。改动都在代码与界面上 | 不需要 |
|
||||
|
||||
三次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
|
||||
四次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
|
||||
|
||||
```bash
|
||||
docker compose logs portal | grep -iE "迁移|migrat|提升|promote"
|
||||
@@ -1285,7 +1287,7 @@ docker compose exec portal python manage.py users
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `init [--user U] [--password P]` | 初始化 / 迁移数据库,创建首个管理员(幂等) |
|
||||
| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度) |
|
||||
| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度)。`--debug` **只允许绑回环地址**(Werkzeug 调试器可执行任意代码) |
|
||||
| `collect [-u U]` | 执行一次增量采集(不传 `-u` 则逐个启用账号) |
|
||||
| `migrate-csv [PATH] [-u U]` | 从 v1.0 的 CSV 导入(只读,可重复) |
|
||||
| `import-xlsx PATH [-u U]` | 从官网导出的 xlsx 导入 |
|
||||
@@ -1302,3 +1304,103 @@ docker compose exec portal python manage.py users
|
||||
| `restore <文件名> [--yes] [--no-instance]` | **从备份恢复**(整表替换)。**不加 `--yes` 只打印将要发生什么**;`--no-instance` = 不覆盖 `instance.json` |
|
||||
|
||||
Docker 部署下前面加 `docker compose exec portal`。
|
||||
|
||||
---
|
||||
|
||||
## 十三、安全与隐私基线
|
||||
|
||||
> 面向**要把这个实例挂到公网 / 开放注册**的部署者。
|
||||
> 代码层已经替你关掉的那部分不需要配置;需要**你决定**的部分在 13.2 与 13.4 节。
|
||||
|
||||
### 13.1 代码层已经做到的(不用配置,改坏了反而有风险)
|
||||
|
||||
| 威胁 | 处理方式 | 入口 |
|
||||
|---|---|---|
|
||||
| 撞库 / 爆破 | 图形验证码 + **按来源 IP 硬锁** + 用户名软退避 + 单 IP 尝试总量(40 次 / 5 分钟) | `security.py` |
|
||||
| 用户名枚举 | 口令校验对不存在的账号也走一次**哑哈希**,两条路径耗时对齐;登录失败文案统一 | `security.login_ok` |
|
||||
| 会话固定 / 劫持 | 登录时重建会话(顺带换掉 CSRF 与验证码 id);`HttpOnly` + `SameSite=Lax`;改密 / 重置 / 停用后 `session_ver` 立即作废旧会话 | `security.login_session` |
|
||||
| CSRF | 所有非 GET 请求在 `before_request` 里统一校验;退出登录也改成 POST | `security.check_csrf` |
|
||||
| XSS | Jinja 自动转义 + 前端手动 `esc()`(含 ECharts 的 HTML tooltip)+ CSP + `nosniff` | `security.CSP`、`dashboard/index.html` |
|
||||
| 点击劫持 | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | `apply_security_headers` |
|
||||
| 开放重定向 | `next=` 只允许站内相对路径 | `security.safe_next` |
|
||||
| **响应头注入** | 导出文件名一律先收敛成 ASCII 安全名,再按 RFC 5987 附上原名 | `security.content_disposition` |
|
||||
| **路径穿越** | 备份文件名走 `safe_name()`;导出 CSV 的账号名同样收敛(`manage.py passwd` 建号也补了用户名校验) | `backup.safe_name`、`collect.export_csv` |
|
||||
| **内部信息泄漏** | 兜底异常只回一个事件号(完整堆栈进服务端日志);普通账号拿不到表名与库文件路径 | `web/api.py::_internal`、`query.manifest` |
|
||||
| 资源耗尽 | 采集三道闸门(互斥 / 最小间隔 / 跨度上限)+ 重操作最小间隔 + **读接口限速(240 次 / 分钟 / 账号)** + 容器 `cpus`/`mem_limit`/`pids_limit` | `web/api.py`、`security.api_rate_ok`、`docker-compose.yml` |
|
||||
| 凭证泄漏 | Cookie 静态加密入库;页面与接口只回「长度 + 尾 4 位」;导出与备份都不含明文 | `crypto.py`、`db.get_secret` |
|
||||
| **落盘权限** | 进程 `umask 0077` + 目录 0700 / 文件 0600(SQLite 的 `-wal`/`-shm`、导出 CSV、备份 zip 一并覆盖) | `config.harden_process` |
|
||||
| 调试器 RCE | `--debug` 只允许绑定回环地址,绑对外地址直接拒绝启动 | `manage.py cmd_serve` |
|
||||
|
||||
### 13.2 公网暴露前你必须自己做的
|
||||
|
||||
1. **上 HTTPS,并把开关一起打开**(缺一个就是半截配置):
|
||||
|
||||
```
|
||||
WB_FORCE_HTTPS=1
|
||||
WB_COOKIE_SECURE=1
|
||||
# 反向代理终止 TLS 时还要:WB_TRUST_PROXY=1(且代理必须重写 XFF)
|
||||
```
|
||||
|
||||
见 [第六节](#六反向代理与-https)。
|
||||
|
||||
2. **直连公网时 `WB_TRUST_PROXY` 必须留 0**。置 1 的前提是「**你自己的**反代会重写
|
||||
`X-Forwarded-For`」;否则攻击者每换一个伪造的 XFF,验证码限速、注册配额、
|
||||
登录锁定三道 IP 防线会同时失效。
|
||||
3. **显式指定管理员口令**:`WB_ADMIN_PASSWORD=<强口令>`。留空时程序会生成随机口令并
|
||||
只在启动日志里打印一次,忘了抄就只能 `manage.py passwd` 重置。
|
||||
4. **决定要不要开放注册**:`allow_register`(默认 `1`)。开放就确认
|
||||
`captcha_policy=always` 与 `register_max_per_ip` 的取值。
|
||||
5. **别把备份卷和 `data/` 卷暴露出去**:备份 zip 里含**主密钥**(`instance.json`),
|
||||
拿到它等于拿到全库凭证的明文。备份目录默认不在 `data/` 内,容器里挂的是独立卷。
|
||||
6. **在应用前面再加一层限制**(可选但强烈建议):反代 / 云安全组做 IP 白名单或接 WAF。
|
||||
应用层的限速是最后一道,不该是唯一一道。
|
||||
|
||||
### 13.3 隐私:本系统存了哪些个人数据
|
||||
|
||||
| 数据 | 存在哪 | 谁能看到 | 备注 |
|
||||
|---|---|---|---|
|
||||
| **对话正文**(`User Prompt`) | `usage_records.prompt`;管理员导出时落到 `data/exports/*.csv` | 仅本人(页面 / 导出);管理员在**备份归档**里也能拿到 | 本系统里最敏感的一类数据。入库按 `max_prompt` 截断(默认 2048 字符) |
|
||||
| 云端凭证(Cookie / UA) | `settings.value`,**密文** | 仅本人,且只能看到「长度 + 尾 4 位」 | 主密钥在 `data/instance.json`(0600) |
|
||||
| 账号资料 | `users`(用户名 / 显示名 / 邮箱) | 本人 + 管理员 | 邮箱选填 |
|
||||
| 来源 IP | `users.register_ip`、`users.last_login_ip`、`audit_log.ip` | 仅管理员 | 用于每日注册配额与事后追责 |
|
||||
| 采集运行日志 | `collect_runs.detail`、`logs/app.log` | 本人(自己的);`/logs` 整页仅管理员 | 访问日志只记路径,**不记 query string** —— 否则 `?q=<搜索词>` 会把 prompt 片段带进日志文件 |
|
||||
|
||||
三条数据边界由**服务端**强制,不靠界面隐藏:
|
||||
|
||||
- 普通账号的一切读写都带 `user_id = 当前账号`;越权写入直接 `400`,并记一条
|
||||
`settings_rejected` 审计;
|
||||
- 管理员在「用户管理」看得到账号列表与登录 IP,但**看不到任何人的用量与凭证**;
|
||||
- 凭证明文只在「真正要拿它对外发请求」的那一刻解出来,不进日志、不进响应体。
|
||||
|
||||
### 13.4 保留期与删除:需要你自己定的部分
|
||||
|
||||
**代码刻意不做自动清理** —— 自动删数据比留数据危险得多。所以下面这些是部署者的事:
|
||||
|
||||
- `prompt` 与 `audit_log` 会一直留着。需要按时间裁剪时(**先 `manage.py backup`**):
|
||||
|
||||
```sql
|
||||
-- 例:清掉 180 天前的对话正文(保留行与积分,只去正文)
|
||||
UPDATE usage_records SET prompt='' WHERE day < date('now','-180 day');
|
||||
-- 例:清掉 365 天前的审计
|
||||
DELETE FROM audit_log WHERE at < date('now','-365 day');
|
||||
```
|
||||
|
||||
改完用 `manage.py vacuum` 回收空间。
|
||||
- **删除账号已经是「被遗忘权」的落地方式**:「用户管理 → 删除」默认**连同该账号的用量
|
||||
数据与 Cookie 一起删除**;用户自己可以走「个人中心 → 导出我的全部数据」把数据带走。
|
||||
- **导出文件的留存**:`data/exports/*.csv` 是对话正文的明文副本,没有谁会自动清。
|
||||
容器里它在 `wb_data` 卷内 —— 保留多久由你决定,别让它一直躺在磁盘上。
|
||||
|
||||
### 13.5 上线前的自检
|
||||
|
||||
```bash
|
||||
# 1) 应用层回归(第 9 节专门验「对外暴露面」:响应头收敛 / 异常不外泄 / 读接口限速)
|
||||
python tools/smoke.py
|
||||
|
||||
# 2) 从外部看到的响应头
|
||||
# 期望看到 CSP / Permissions-Policy / X-Frame-Options / nosniff / Referrer-Policy
|
||||
curl -sI https://你的域名/login | grep -iE 'content-security|permissions-policy|x-frame|nosniff|referrer'
|
||||
|
||||
# 3) 确认没有把调试器挂出去(这条应当直接报错退出,而不是启动成功)
|
||||
python manage.py serve --debug
|
||||
```
|
||||
|
||||
+2
-4
@@ -413,13 +413,11 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
|
||||
页面上、接口里都拿不到明文。若显示「**无法解密**」的红字横幅,说明实例主密钥被换过,
|
||||
重新粘贴一次即可。
|
||||
|
||||
> 凭证卡片上会有一行提示写着「**这一块是你唯一可以修改的配置**」——
|
||||
> 看到它就找对地方了。
|
||||
> 凭证卡片上会有一行提示写着「普通账号只有这一块可改」——看到它就找对地方了。
|
||||
|
||||
### 9.2 采集参数(只读)
|
||||
|
||||
普通账号在这一块看到的是**只读表格**,输入框不可编辑,页面上还有一张
|
||||
「**为什么采集参数是只读的**」说明卡:
|
||||
普通账号在这一块看到的是**只读表格**,输入框不可编辑:
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
常用:
|
||||
python manage.py init 初始化数据库(建表 + 默认配置 + 管理员)
|
||||
python manage.py serve 启动 Web(0.0.0.0:8848,进程内含调度线程)
|
||||
python manage.py serve --port 9000 --debug 开发模式(reloader 下调度只启动一份)
|
||||
python manage.py serve --debug --host 127.0.0.1 开发模式(--debug 只允许绑回环地址)
|
||||
python manage.py collect 为**所有已启用账号**各跑一次增量采集
|
||||
python manage.py collect -u alice 只为 alice 采集
|
||||
python manage.py migrate-csv [文件] 从旧版 CSV 存档导入(默认自动探测路径)
|
||||
@@ -127,9 +127,18 @@ def cmd_init(args):
|
||||
|
||||
def cmd_serve(args):
|
||||
from workbuddy_portal import create_app
|
||||
app = create_app(start_scheduler=not args.no_scheduler)
|
||||
host = args.host or config.DEFAULT_HOST
|
||||
port = args.port or config.DEFAULT_PORT
|
||||
# --debug 会打开 Werkzeug 的交互式调试器:**任意 Python 代码执行**。
|
||||
# 它虽然有 PIN,但 PIN 会打印在控制台、且在某些部署下可由机器信息推导出来,
|
||||
# 把它绑到 0.0.0.0(默认值就是这个)等于把 RCE 直接挂上公网。
|
||||
# 这里直接拒绝:调试要么在本机回环地址上做,要么就别开调试器。
|
||||
if args.debug and host not in ("127.0.0.1", "localhost", "::1"):
|
||||
_p("[error] --debug 只能绑定回环地址(当前 --host %s)。" % host)
|
||||
_p(" Werkzeug 调试器可执行任意 Python 代码,绑到对外地址等于开放 RCE。")
|
||||
_p(" 本机调试请用:python manage.py serve --debug --host 127.0.0.1")
|
||||
return 2
|
||||
app = create_app(start_scheduler=not args.no_scheduler)
|
||||
if args.debug:
|
||||
app.run(host=host, port=port, debug=True)
|
||||
return
|
||||
@@ -420,6 +429,13 @@ def cmd_passwd(args):
|
||||
if not pwd:
|
||||
_p("[error] 新账号必须给出密码")
|
||||
return 2
|
||||
# 建号必须走与注册页同一套用户名校验。绕开它的后果不是「名字不好看」:
|
||||
# 导出文件名与下载响应头都直接拼账号名,一个带 `/`、`..`、引号或 CR/LF
|
||||
# 的名字会变成路径穿越 / 响应头注入。这里从根上堵掉。
|
||||
problem = security.username_problem(args.user)
|
||||
if problem:
|
||||
_p("[error] 用户名不合规:%s" % problem)
|
||||
return 2
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||
" created_at) VALUES(?,?,?,?,'active',?)",
|
||||
@@ -705,6 +721,9 @@ def main():
|
||||
if not getattr(args, "func", None):
|
||||
ap.print_help()
|
||||
return 0
|
||||
# 先收紧 umask 再建目录:这样 SQLite 库、-wal/-shm、导出 CSV、备份 zip
|
||||
# 从「被创建的那一刻」就是 owner-only,不必逐个事后 chmod。
|
||||
config.harden_process()
|
||||
config.ensure_dirs()
|
||||
return args.func(args) or 0
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ import sys
|
||||
SKIP_DIRS = {
|
||||
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build",
|
||||
".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".idea", ".vscode",
|
||||
# AI 助手的工作记忆与技能缓存:是开发过程产物,不是项目文档。
|
||||
# 它已在 .gitignore 里,这里一并跳过,免得自检报告被一堆无关 .md 淹没
|
||||
# (而且那些笔记里的链接是给助手看的,不该按项目文档的规则去校验)。
|
||||
".codebuddy",
|
||||
}
|
||||
|
||||
# markdown 链接:[文本](目标)
|
||||
|
||||
+117
-1
@@ -17,6 +17,7 @@
|
||||
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.)
|
||||
5. CSV 导出可被标准 csv 解析、列数一致
|
||||
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
|
||||
9. 对外暴露面:下载响应头收敛 / 内部异常不外泄 / 读接口限速 / 安全响应头
|
||||
|
||||
权限模型(改断言前先读这一行):
|
||||
普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||||
@@ -487,7 +488,7 @@ def run() -> None:
|
||||
chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf)
|
||||
chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf)
|
||||
chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf)
|
||||
chk("⑨ 配置页说明范围", "唯一可以修改" in cf)
|
||||
chk("⑨ 配置页说明范围", "只有这一块可改" in cf)
|
||||
# ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口)
|
||||
st, sj = page(cli, "/api/status")
|
||||
chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st)
|
||||
@@ -774,6 +775,121 @@ def run() -> None:
|
||||
bconn.commit()
|
||||
bconn.close()
|
||||
|
||||
# ---------------- 9. 对外暴露面(安全与隐私基线) ----------------
|
||||
# 这一节盯的是「直接挂到公网」时最容易漏掉的三类东西:
|
||||
# ① 下载文件名 / 响应头注入 —— 用户名并不总是注册正则的产物
|
||||
# (manage.py passwd 建号、老库升级上来的名字都可能带引号或 CR/LF);
|
||||
# ② 内部异常直出 —— 原始异常文本会带绝对路径与 SQL 片段,是踩点的现成材料;
|
||||
# ③ 读接口没有刹车 —— 一个注册账号循环调 /api/bundle 就能持续吃满 CPU。
|
||||
print("== 9. 对外暴露面:响应头收敛 / 异常不外泄 / 读接口限速 ==")
|
||||
|
||||
for bad in ('a"b.csv', "a\r\nX-Evil: 1", "../../etc/passwd", "a\\b.zip", "..",
|
||||
".", " ", "", "中文\n名.csv"):
|
||||
got = security.safe_filename(bad, fallback="download")
|
||||
chk("⑨ safe_filename 收敛 %r" % bad,
|
||||
bool(got) and not any(c in got for c in '"\\\r\n/') and ".." not in got,
|
||||
"-> %r" % got)
|
||||
|
||||
cd = security.content_disposition('a"b\r\nX-Evil: 1.zip')
|
||||
quoted = cd.split('filename="', 1)[1].split('"', 1)[0] if 'filename="' in cd else "?"
|
||||
chk("⑨ content_disposition 的 ASCII 名段无引号/换行/路径",
|
||||
not any(c in quoted for c in '"\\\r\n/'), "-> %r" % cd[:80])
|
||||
chk("⑨ content_disposition 带 RFC 5987 原名段", "filename*=UTF-8''" in cd)
|
||||
|
||||
# 端到端:直接造一个「非法用户名」的账号,看真实导出响应头是否仍然干净。
|
||||
# 走 SQL 插入而不是接口,正是为了模拟「老库里已经有这种名字」的现状。
|
||||
api_src = open(os.path.join(BASE, "workbuddy_portal", "web", "api.py"),
|
||||
encoding="utf-8").read()
|
||||
evil = 'smoke_ev"%s\\x' % _rand(4)
|
||||
nconn = db.connect()
|
||||
try:
|
||||
nconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||
"status,created_at) VALUES(?,?,'越权探针',0,'active',?)",
|
||||
(evil, security.hash_password("Smoke-Pass1"), db.now_str()))
|
||||
eid = nconn.execute("SELECT id FROM users WHERE username=?", (evil,)).fetchone()["id"]
|
||||
with app.test_client() as ce:
|
||||
login(ce, eid)
|
||||
for path, what in (("/records/export", "按明细导出"), ("/profile/export", "个人数据包")):
|
||||
h = ce.get(path).headers.get("Content-Disposition", "")
|
||||
chk("⑨ %s 响应头已收敛" % what,
|
||||
bool(h) and "\r" not in h and "\n" not in h and 'filename="' in h,
|
||||
"-> %r" % h[:80])
|
||||
finally:
|
||||
nconn.execute("DELETE FROM users WHERE username=?", (evil,))
|
||||
nconn.commit()
|
||||
nconn.close()
|
||||
|
||||
# ② 接口层的兜底 catch 不能再把原始异常文本回给客户端。
|
||||
# 注意断言的是「兜底分支」而不是「所有 str(e)」:BadParam / Busy /
|
||||
# NotReady / ApiError / BackupError 这些都是**面向用户写的**业务异常,
|
||||
# 文案本身就是要给用户看的,把它们也禁掉是过度收紧。
|
||||
# 真正会带出绝对路径与 SQL 的只有 `except Exception` 那一条路。
|
||||
generic = re.findall(r"except Exception as e:[^\n]*\n([^\n]*)", api_src)
|
||||
chk("⑨ 每个 except Exception 都走 _internal(不回原始异常文本)",
|
||||
bool(generic) and all("_internal(" in g for g in generic),
|
||||
"n=%d" % len(generic))
|
||||
|
||||
# ③ 读接口限速:把阈值临时调小,验机制本身(真阈值在 security._API_RATE_MAX)
|
||||
chk("⑨ 读接口阈值给得足够宽松(不会误伤正常翻页)",
|
||||
security._API_RATE_MAX >= 60, "max=%d/60s" % security._API_RATE_MAX)
|
||||
old_max = security._API_RATE_MAX
|
||||
try:
|
||||
security._API_RATE_MAX = 5
|
||||
security.api_rate_reset()
|
||||
with app.test_client() as cr:
|
||||
login(cr, ADMIN)
|
||||
codes = [page(cr, "/api/status")[0] for _ in range(6)]
|
||||
chk("⑨ 读接口限速:前 5 次放行", codes[:5] == [200] * 5, "codes=%s" % codes)
|
||||
chk("⑨ 读接口限速:第 6 次 429", codes[5] == 429, "codes=%s" % codes)
|
||||
finally:
|
||||
security._API_RATE_MAX = old_max
|
||||
security.api_rate_reset()
|
||||
|
||||
# ④ 安全响应头:对外部署时这些是扫描器与浏览器共同依赖的基线
|
||||
with app.test_client() as ch_:
|
||||
h = ch_.get("/login").headers
|
||||
for name in ("Content-Security-Policy", "Permissions-Policy",
|
||||
"X-Content-Type-Options", "X-Frame-Options", "Referrer-Policy"):
|
||||
chk("⑨ 响应头 %s 已下发" % name, name in h)
|
||||
chk("⑨ 会话 Cookie 标了 HttpOnly", "HttpOnly" in h.get("Set-Cookie", ""))
|
||||
|
||||
# ⑥ 内部实现细节不下发给普通账号:表名、库文件路径这些是踩点材料,
|
||||
# 普通账号看自己的数据,没有任何理由知道正本落在哪、叫什么表。
|
||||
probe = "smoke_p_%s" % _rand()
|
||||
nconn2 = db.connect()
|
||||
try:
|
||||
nconn2.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||
"status,created_at) VALUES(?,?,'隐私探针',0,'active',?)",
|
||||
(probe, security.hash_password("Smoke-Pass1"), db.now_str()))
|
||||
pid = nconn2.execute("SELECT id FROM users WHERE username=?",
|
||||
(probe,)).fetchone()["id"]
|
||||
with app.test_client() as cu:
|
||||
login(cu, pid)
|
||||
ju = json.loads(page(cu, "/api/manifest")[1])
|
||||
chk("⑨ 普通账号拿不到「数据源」清单", ju.get("sources") == [],
|
||||
"sources=%s" % ju.get("sources"))
|
||||
chk("⑨ 普通账号拿不到库文件路径", not ju.get("archive"),
|
||||
"archive=%r" % ju.get("archive"))
|
||||
chk("⑨ 但仍能拿到自己的存档总量(不是把整块砍掉)",
|
||||
ju.get("totals", {}).get("records") == 0, "records=%s"
|
||||
% ju.get("totals", {}).get("records"))
|
||||
with app.test_client() as ca:
|
||||
login(ca, ADMIN)
|
||||
ja = json.loads(page(ca, "/api/manifest")[1])
|
||||
chk("⑨ 管理员仍能看到「数据源」清单", bool(ja.get("sources")),
|
||||
"n=%d" % len(ja.get("sources") or []))
|
||||
finally:
|
||||
nconn2.execute("DELETE FROM users WHERE username=?", (probe,))
|
||||
nconn2.commit()
|
||||
nconn2.close()
|
||||
|
||||
# ⑤ 大屏页的数据插值必须全部转义(这里只钉住这次修掉的那几处,
|
||||
# 它们是「模型名 / 客户端名来自上游接口」这条链路上最短的几根引线)
|
||||
dash = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "dashboard",
|
||||
"index.html"), encoding="utf-8").read()
|
||||
for probe in ("<b>${p.name}", "<b>${d.name}", "${x.model}</td>", "${x.client}</td>"):
|
||||
chk("⑨ 大屏页已转义 %s" % probe, probe not in dash)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("工程目录:%s\n" % BASE)
|
||||
|
||||
+18
-1
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
|
||||
|
||||
from . import config, db, security
|
||||
|
||||
__version__ = "1.4.0"
|
||||
__version__ = "1.5.0"
|
||||
PROJECT_NAME = config.PROJECT_NAME
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ def _setup_logging(app):
|
||||
|
||||
|
||||
def create_app(start_scheduler=True, do_init_db=True, **overrides):
|
||||
# 必须在任何写盘动作之前:把进程 umask 收到 0077,此后新建的 SQLite 库、
|
||||
# -wal / -shm、导出 CSV、备份 zip、日志文件一律是 owner-only。
|
||||
# 数据目录里躺着对话正文与主密钥,对外部署时同机其它用户不该读得到。
|
||||
config.harden_process()
|
||||
app = Flask(__name__,
|
||||
template_folder="web/templates",
|
||||
static_folder="web/static",
|
||||
@@ -100,6 +104,19 @@ def create_app(start_scheduler=True, do_init_db=True, **overrides):
|
||||
return jsonify({"ok": False, "error": "forbidden", "message": "没有权限"}), 403
|
||||
return render_template("error.html", code=403, message="没有权限"), 403
|
||||
|
||||
@app.errorhandler(413)
|
||||
def _413(e):
|
||||
"""请求体过大(MAX_CONTENT_LENGTH)。
|
||||
|
||||
不加这一层的话 Flask 会吐一个默认 HTML 页,而接口调用方拿到的是
|
||||
HTML 而不是 JSON —— 前端 `r.json()` 会抛异常,最后在界面上表现成
|
||||
「请求失败:Unexpected token <」。这里统一成与其它错误一致的形状。
|
||||
"""
|
||||
msg = "请求体过大(上限 %d MB)" % (app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024))
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"ok": False, "error": "too_large", "message": msg}), 413
|
||||
return render_template("error.html", code=413, message=msg), 413
|
||||
|
||||
@app.errorhandler(500)
|
||||
def _500(e):
|
||||
app.logger.exception("内部错误")
|
||||
|
||||
@@ -231,6 +231,9 @@ def create(conn, trigger="manual", actor=None, note=""):
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
# 归档里是「全库 + 主密钥」,权限等同管理员口令,落盘后立刻收紧。
|
||||
# (进程 umask 已收到 0077,这里额外兜住「同名文件早已存在」的情况。)
|
||||
config.harden_file(dest)
|
||||
size = os.path.getsize(dest)
|
||||
digest = _sha256(dest)
|
||||
db.audit(conn, "backup_create", actor or "system",
|
||||
|
||||
+13
-2
@@ -21,6 +21,7 @@
|
||||
"""
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -484,11 +485,20 @@ def export_csv(conn, uid, path=None, username=None):
|
||||
|
||||
文件名带账号名:多用户下所有人导出到同一个目录,
|
||||
不带归属就会互相覆盖。
|
||||
|
||||
账号名必须收敛后才能拼进路径:`manage.py passwd` 建号时**不校验**用户名,
|
||||
老库升级上来的名字也可能带 `/` 或 `..`。直接拼接就是路径穿越 ——
|
||||
一个叫 `..\\..\\x` 的账号能把 CSV 写到数据目录之外。
|
||||
这里只保留 [A-Za-z0-9._-],结果固定落在 EXPORT_DIR 之下。
|
||||
|
||||
另:导出文件里是**对话正文的明文**,所以落盘后立刻收紧权限
|
||||
(进程 umask 已经收到 0077,这里是针对「文件早已存在、权限是旧的」那种情况)。
|
||||
"""
|
||||
if path is None:
|
||||
tag = username or ("u%s" % uid)
|
||||
tag = re.sub(r"[^A-Za-z0-9._-]", "_", str(username or ""))[:48].strip("._-")
|
||||
tag = tag or ("u%s" % uid)
|
||||
path = os.path.join(config.EXPORT_DIR, "usage_records_%s.csv" % tag)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
rows = conn.execute("SELECT request_id,credits,prompt,model,client,ts FROM usage_records "
|
||||
"WHERE user_id=? ORDER BY ts, request_id", (uid,))
|
||||
n = 0
|
||||
@@ -499,6 +509,7 @@ def export_csv(conn, uid, path=None, username=None):
|
||||
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
|
||||
r["model"], r["client"], r["ts"]])
|
||||
n += 1
|
||||
config.harden_file(path)
|
||||
return path, n
|
||||
|
||||
|
||||
|
||||
@@ -341,9 +341,58 @@ PROFILE_EMAIL_MAX = 128
|
||||
# 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。
|
||||
|
||||
|
||||
# ---------------- 落盘权限(对外部署的隐私基线)----------------
|
||||
# 数据目录里躺着的东西比想象中敏感:
|
||||
# * usage_records.prompt —— 用户与 AI 的完整对话正文
|
||||
# * settings.value —— 凭证密文;instance.json —— 拆密文的主密钥
|
||||
# * exports/*.csv —— 上面那份对话正文的**明文**副本
|
||||
# * backups/*.zip —— 「全库 + 主密钥」的压缩包
|
||||
# 默认 umask 022 会把它们留成 0644 / 0755,也就是**同机任何用户都能读**。
|
||||
# 在对外提供服务的机器上,这不是理论风险(多租户主机、共享 CI runner、
|
||||
# 被入侵的低权限进程都是现实场景)。
|
||||
#
|
||||
# 处理方式分两层:
|
||||
# 1. `harden_process()` 把进程 umask 收到 0077 —— 此后本进程新建的**一切**
|
||||
# 文件都天然是 owner-only(SQLite 的 -wal/-shm 也包括在内)。
|
||||
# 刻意选择「一条设置管全部」而不是逐个 chmod:逐点 chmod 一定会漏,
|
||||
# 而漏掉的那个文件里往往正好是最新的对话正文。
|
||||
# 2. `harden_dir()` / `harden_file()` 用于收紧**已经存在**的目录与文件
|
||||
# (老版本留下的 0755 目录、手工拷进来的备份等)。
|
||||
# Windows 没有 umask 这一套(权限走 ACL),两个函数直接返回。
|
||||
def harden_process():
|
||||
"""把进程 umask 收紧到 0077。必须在任何写盘动作之前调用。"""
|
||||
if os.name != "posix":
|
||||
return
|
||||
try:
|
||||
os.umask(0o077)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def harden_dir(path, mode=0o700):
|
||||
"""把目录权限收紧到 owner-only。失败不致命(只读 / 网络文件系统)。"""
|
||||
if os.name != "posix" or not path:
|
||||
return
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def harden_file(path, mode=0o600):
|
||||
"""把文件权限收紧到 owner-only。渲染/导出/备份落盘后调用。"""
|
||||
if os.name != "posix" or not path:
|
||||
return
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR, BACKUP_DIR):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
harden_dir(d)
|
||||
|
||||
|
||||
def _instance_read():
|
||||
|
||||
+21
-10
@@ -259,8 +259,14 @@ def day_list(conn, uid=0):
|
||||
"SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day", (uid or 0,))]
|
||||
|
||||
|
||||
def manifest(conn, uid):
|
||||
"""该账号的数据清单 + 采集健康状态(凭证是否已配置 = 本人是否配了 Cookie)。"""
|
||||
def manifest(conn, uid, sources=True):
|
||||
"""该账号的数据清单 + 采集健康状态(凭证是否已配置 = 本人是否配了 Cookie)。
|
||||
|
||||
`sources=False` 时不下发「数据源」清单(表名、库文件名这类内部实现细节)。
|
||||
对外提供服务后,普通注册用户没有任何理由知道正本叫什么表、库文件在哪 ——
|
||||
这些是踩点阶段可以直接用的材料,属于「能不给就不给」的那一类。
|
||||
大屏页的「数据源」卡片会因此对非管理员留空,这是预期行为。
|
||||
"""
|
||||
t = totals(conn, uid)
|
||||
db_bytes = conn.execute("PRAGMA page_count").fetchone()[0] * \
|
||||
conn.execute("PRAGMA page_size").fetchone()[0]
|
||||
@@ -272,10 +278,13 @@ def manifest(conn, uid):
|
||||
return {
|
||||
"schema": SCHEMA_VERSION,
|
||||
"generated": db.now_str(),
|
||||
"archive": "data/usage.sqlite",
|
||||
"producer": "workbuddy-portal(Flask + SQLite)",
|
||||
"note": "数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果,"
|
||||
"且只统计当前登录账号的归属数据。",
|
||||
# archive / producer / note 只在管理员侧有意义(普通用户看的是自己的数据,
|
||||
# 不需要知道正本落在哪个文件、由什么进程产出)。
|
||||
"archive": "data/usage.sqlite" if sources else "",
|
||||
"producer": "workbuddy-portal(Flask + SQLite)" if sources else "",
|
||||
"note": ("数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果,"
|
||||
"且只统计当前登录账号的归属数据。") if sources
|
||||
else "daily/dims/top 均为 SQL 实时聚合结果,且只统计当前登录账号的归属数据。",
|
||||
"totals": {"records": t["records"], "credits": t["credits"], "calls": t["calls"],
|
||||
"freeCalls": t["freeCalls"], "billableCalls": t["billableCalls"],
|
||||
"days": day_list(conn, uid), "months": mons,
|
||||
@@ -285,7 +294,7 @@ def manifest(conn, uid):
|
||||
"SELECT COALESCE(MAX(credits),0) FROM usage_records WHERE user_id=?",
|
||||
(uid,)).fetchone()[0] or 0.0)},
|
||||
"months": mons,
|
||||
"sources": [
|
||||
"sources": ([
|
||||
{"path": "usage_records", "role": "明细正本(SQLite 表,按账号隔离)",
|
||||
"count": t["records"], "bytes": db_bytes},
|
||||
{"path": "daily 聚合视图", "role": "逐日聚合(SQL GROUP BY day)",
|
||||
@@ -295,7 +304,7 @@ def manifest(conn, uid):
|
||||
{"path": "top 查询", "role": "单笔消耗榜(ORDER BY credits DESC)",
|
||||
"count": DEFAULT_TOP_N, "bytes": 0},
|
||||
{"path": "collect_runs", "role": "采集运行历史(本账号)", "count": runs, "bytes": 0},
|
||||
],
|
||||
] if sources else []),
|
||||
"focusDay": (last_run["win_to"] or "")[:10] if last_run else "",
|
||||
"health": {"cookie": cred["set"] and not cred["broken"],
|
||||
"cookieChars": cred["chars"],
|
||||
@@ -306,7 +315,7 @@ def manifest(conn, uid):
|
||||
|
||||
|
||||
def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
||||
records_cap=BUNDLE_RECORDS_CAP):
|
||||
records_cap=BUNDLE_RECORDS_CAP, sources=True):
|
||||
"""大屏页一次请求拿齐所需数据(全部限定在当前账号内)。
|
||||
|
||||
窗口裁剪:records(明细)、dims(维度)、totals(KPI)随 frm/to 变化。
|
||||
@@ -314,6 +323,8 @@ def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
||||
|
||||
records 有上限(records_cap)并在响应里标注 recordsTruncated,
|
||||
避免存档长大后「全部」区间把整包明细都压到浏览器。
|
||||
|
||||
`sources=False` 时不下发内部实现清单(表名 / 库文件),见 `manifest()`。
|
||||
"""
|
||||
frm, to = norm_window(frm, to)
|
||||
tot = totals(conn, uid, frm, to)
|
||||
@@ -322,7 +333,7 @@ def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
||||
recs = records(conn, uid, frm, to, excerpt=excerpt,
|
||||
limit=records_cap if truncated else 0, newest_first=truncated)
|
||||
return {
|
||||
"manifest": manifest(conn, uid),
|
||||
"manifest": manifest(conn, uid, sources=sources),
|
||||
"daily": daily(conn, uid), # 全量逐日(体量小,供日历与日期轴)
|
||||
"dims": dims(conn, uid, frm, to), # 窗口内维度
|
||||
"top": top(conn, uid, None, None, top_n)["items"], # 该账号全局 TOP 榜
|
||||
|
||||
+95
-1
@@ -31,6 +31,7 @@ import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from flask import (current_app, flash, g, jsonify, redirect, render_template,
|
||||
request, session, url_for)
|
||||
@@ -253,6 +254,67 @@ def action_wait_left(key):
|
||||
return max(0, int((_actions.get(key) or 0) - time.time()))
|
||||
|
||||
|
||||
# ---------------- 通用读接口限速(对外部署的刹车) ----------------
|
||||
# 登录有 IP 锁定、注册有配额 + 验证码、采集有最小间隔 —— 但**读接口没有刹车**。
|
||||
# 对外提供服务后,一个注册账号循环调 /api/bundle(它要算全量逐日聚合,还会
|
||||
# 下发最多 2 万条明细)就能持续吃掉 CPU 与出口带宽;这不需要任何漏洞,
|
||||
# 只要把浏览器 F12 里的那个请求放进 for 循环。
|
||||
# 所以这里按「账号(未登录时按来源 IP)」加一个滑动窗口上限。
|
||||
# 阈值刻意给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到它。
|
||||
_API_RATE_MAX = 240 # 每窗口允许的请求数
|
||||
_API_RATE_WINDOW = 60 # 窗口长度(秒)
|
||||
_API_HITS_MAX_KEYS = 8192 # 字典上限,防止海量来源把内存撑爆
|
||||
_api_hits = {} # key -> [count, window_started_at]
|
||||
|
||||
|
||||
def api_rate_ok(key):
|
||||
"""返回 (是否放行, 还需等待秒数)。"""
|
||||
now = time.time()
|
||||
if len(_api_hits) > _API_HITS_MAX_KEYS:
|
||||
_api_hits.clear()
|
||||
cur = _api_hits.get(key)
|
||||
if cur is None or now - cur[1] > _API_RATE_WINDOW:
|
||||
_api_hits[key] = [1, now]
|
||||
return True, 0
|
||||
cur[0] += 1
|
||||
if cur[0] > _API_RATE_MAX:
|
||||
return False, int(_API_RATE_WINDOW - (now - cur[1])) + 1
|
||||
return True, 0
|
||||
|
||||
|
||||
def api_rate_reset():
|
||||
"""清空读接口计数(测试与排障用)。"""
|
||||
_api_hits.clear()
|
||||
|
||||
|
||||
# ---------------- 下载文件名 / 响应头 ----------------
|
||||
# 导出文件名里含用户名,而用户名**并不总是**注册接口那条正则的产物:
|
||||
# `manage.py passwd` 建号时不做校验,老库升级上来的名字也可能带各种字符。
|
||||
# 一旦名字里有引号或 CR/LF,直接拼进响应头就是**响应头注入**(响应拆分):
|
||||
# 引号之后的内容会被下游解析成新的头。所以这里一律先收敛。
|
||||
_UNSAFE_FN = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def safe_filename(name, fallback="download", maxlen=64):
|
||||
"""把任意字符串收敛成可安全放进头部 / 文件系统的 ASCII 名。"""
|
||||
s = _UNSAFE_FN.sub("_", str(name or "")).replace("..", "_").strip("._-")
|
||||
s = s[:maxlen].strip("._-")
|
||||
return s or fallback
|
||||
|
||||
|
||||
def content_disposition(filename, fallback="download"):
|
||||
"""构造安全的 Content-Disposition。
|
||||
|
||||
两段都给:
|
||||
* `filename=` —— 纯 ASCII 收敛名,任何客户端都认,且不可能含引号/换行
|
||||
* `filename*=` —— RFC 5987 的 UTF-8 原名,让中文名在浏览器里仍然好看
|
||||
只用后者会有老客户端兼容问题,只用前者会把中文名变成一串下划线。
|
||||
"""
|
||||
raw = str(filename or "").replace("\\", "/").split("/")[-1].strip() or fallback
|
||||
return 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (
|
||||
safe_filename(raw, fallback=fallback), urllib.parse.quote(raw, safe=""))
|
||||
|
||||
|
||||
# ---------------- 口令 / 用户名策略 ----------------
|
||||
_USERNAME_RE = re.compile(config.USERNAME_RE)
|
||||
|
||||
@@ -313,6 +375,20 @@ _check_password = password_problem
|
||||
|
||||
|
||||
# ---------------- 登录 ----------------
|
||||
# 口令校验的**哑哈希**:用户名不存在时也走一次同代价的 PBKDF2。
|
||||
# 不补这一步的话,「账号不存在」会比「口令错误」快一到两个数量级
|
||||
# (前者根本不做哈希计算),攻击者拿一个秒表就能枚举出哪些用户名真实存在。
|
||||
# 对外开放时用户名枚举通常是撞库的第一步,所以两条路径的耗时必须对齐。
|
||||
_DUMMY_HASH = None
|
||||
|
||||
|
||||
def _dummy_verify(password):
|
||||
global _DUMMY_HASH
|
||||
if _DUMMY_HASH is None:
|
||||
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
verify_password(_DUMMY_HASH, password)
|
||||
|
||||
|
||||
def login_ok(conn, username, password):
|
||||
"""校验口令。返回 (user_row, error_message)。
|
||||
|
||||
@@ -321,7 +397,10 @@ def login_ok(conn, username, password):
|
||||
"""
|
||||
username = (username or "").strip()
|
||||
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
if row is None or not verify_password(row["password_hash"], password):
|
||||
if row is None:
|
||||
_dummy_verify(password)
|
||||
return None, "用户名或密码不正确"
|
||||
if not verify_password(row["password_hash"], password):
|
||||
return None, "用户名或密码不正确"
|
||||
if (row["status"] or "active") != "active":
|
||||
return None, "该账号已被停用,请联系管理员"
|
||||
@@ -573,6 +652,11 @@ def apply_security_headers(resp):
|
||||
resp.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
resp.headers.setdefault("Content-Security-Policy", CSP)
|
||||
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
# 本项目不需要任何浏览器外设能力,显式关掉:缩小「被内嵌页面 / 被第三方
|
||||
# 脚本滥用」时的可用面,同时也是对外部署时安全扫描的基本要求。
|
||||
resp.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
"geolocation=(), microphone=(), camera=(), payment=(), usb=()")
|
||||
# HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它,
|
||||
# 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。
|
||||
# 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。
|
||||
@@ -615,6 +699,16 @@ def init_app(app):
|
||||
@app.before_request
|
||||
def _guard():
|
||||
g.wb_t0 = time.time()
|
||||
# 读接口限速放在最前面:被限流的请求不该再走后面那些更贵的事情
|
||||
# (HTTPS 判断、CSRF、会话校验)。
|
||||
# 键用 session 里的 uid(不做回查),未登录时退化成来源 IP ——
|
||||
# 这里只是限速,不承担鉴权职责,所以不需要一个「可信」的身份。
|
||||
if request.path.startswith("/api/"):
|
||||
who = session.get("uid") or ("ip:" + client_ip())
|
||||
ok, wait = api_rate_ok("api:%s" % who)
|
||||
if not ok:
|
||||
return jsonify({"ok": False, "error": "rate_limited",
|
||||
"message": "请求过于频繁,请 %d 秒后再试" % wait}), 429
|
||||
if needs_https_redirect():
|
||||
url = request.url.replace("http://", "https://", 1)
|
||||
return redirect(url, code=301)
|
||||
|
||||
+29
-6
@@ -20,7 +20,9 @@
|
||||
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
|
||||
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file, session
|
||||
@@ -29,6 +31,7 @@ from .. import backup, collect, config, db, query, scheduler, security
|
||||
from ..security import admin_required, current_user, is_admin, login_required
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
log = logging.getLogger("wb.api")
|
||||
|
||||
|
||||
def _uid():
|
||||
@@ -36,6 +39,23 @@ def _uid():
|
||||
return u["id"] if u else 0
|
||||
|
||||
|
||||
def _internal(e, where):
|
||||
"""内部异常的**唯一出口**:细节只进日志,响应只给一个可对账的事件号。
|
||||
|
||||
以前这里是 `jsonify({"message": str(e)})` —— 把原始异常文本原样回给客户端。
|
||||
那等于把服务端内部结构送给调用方:绝对路径(`/app/workbuddy_portal/...`)、
|
||||
SQL 语句、`sqlite3` 的错误、备份目录位置都可能出现在里面。
|
||||
对一个对外开放的站点来说,这些正好是踩点阶段最想要的信息。
|
||||
现在改成:完整堆栈写进服务端日志(带事件号),客户端只拿到事件号 ——
|
||||
既不影响「出事了去查日志」的运维动作,也不泄露任何内部细节。
|
||||
"""
|
||||
eid = secrets.token_hex(4)
|
||||
log.exception("[%s] %s 处理失败", eid, where)
|
||||
return jsonify({"ok": False, "error": "internal",
|
||||
"message": "服务器内部错误,请稍后重试"
|
||||
"(事件号 %s,管理员可在「日志管理」中检索)" % eid}), 500
|
||||
|
||||
|
||||
def _ip():
|
||||
"""客户端地址。一律走 security.client_ip() —— 见那里的注释:
|
||||
直接取 X-Forwarded-For 第 0 段会让「来源」变成请求方自己填的字符串。"""
|
||||
@@ -87,7 +107,9 @@ def _int(name, default, lo=1, hi=2000):
|
||||
@login_required
|
||||
def api_manifest():
|
||||
u = current_user()
|
||||
m = query.manifest(db.get_db(), u["id"])
|
||||
# 数据源清单(表名 / 库文件)属于内部实现细节,只给管理员;
|
||||
# 普通账号看自己的数据,没有理由知道正本落在哪个文件、叫什么表。
|
||||
m = query.manifest(db.get_db(), u["id"], sources=u["is_admin"])
|
||||
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
|
||||
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
|
||||
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
|
||||
@@ -101,7 +123,8 @@ def api_bundle():
|
||||
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
|
||||
frm, to = _win()
|
||||
return jsonify(query.bundle(db.get_db(), _uid(), frm, to,
|
||||
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
|
||||
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000),
|
||||
sources=is_admin()))
|
||||
|
||||
|
||||
@bp.get("/summary")
|
||||
@@ -319,7 +342,7 @@ def api_collect():
|
||||
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
|
||||
"message": str(e)}), code
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
||||
return _internal(e, "POST /api/collect uid=%s" % uid)
|
||||
db.audit(conn, "collect", u["username"], r["message"], _ip(), uid)
|
||||
return jsonify({"ok": True, "result": r})
|
||||
|
||||
@@ -407,7 +430,7 @@ def api_maintenance(action):
|
||||
else:
|
||||
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
||||
return _internal(e, "POST /api/maintenance/%s uid=%s" % (action, uid))
|
||||
db.audit(conn, "maintenance:" + action, u["username"], msg, _ip(), uid)
|
||||
return jsonify({"ok": True, "message": msg})
|
||||
|
||||
@@ -449,7 +472,7 @@ def api_backup_create():
|
||||
except backup.BackupError as e:
|
||||
return _backup_error(e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
||||
return _internal(e, "POST /api/backups")
|
||||
removed = backup.prune(conn, actor=u["username"])
|
||||
if removed:
|
||||
r["message"] += ";按保留份数清理了 %d 份旧备份" % len(removed)
|
||||
@@ -495,7 +518,7 @@ def api_backup_restore(filename):
|
||||
except backup.BackupError as e:
|
||||
return _backup_error(e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
||||
return _internal(e, "POST /api/backups/restore")
|
||||
return jsonify(r)
|
||||
|
||||
|
||||
|
||||
+108
-125
@@ -1,41 +1,49 @@
|
||||
/* ============================================================
|
||||
WorkBuddy Portal —— 统一视觉系统
|
||||
设计令牌与大屏页(static/dashboard/index.html)保持一致,
|
||||
两处用同一套色板/圆角/间距,避免后台与大屏像两个产品。
|
||||
风格取向:工程控制台。中性灰阶打底,单一强调色(蓝),
|
||||
不用渐变、不用辉光、不做彩色装饰条 —— 信息本身是唯一的主角。
|
||||
设计令牌与大屏页(static/dashboard/index.html)共用一套,
|
||||
两处颜色/圆角/间距保持一致,避免后台与大屏像两个产品。
|
||||
|
||||
约定:模板只使用类名,不依赖任何渐变或阴影细节;
|
||||
因此调整本文件不需要改动任何 Jinja 模板。
|
||||
============================================================ */
|
||||
:root {
|
||||
/* 底色与层次 */
|
||||
--bg: #080d1a;
|
||||
--panel: rgba(255, 255, 255, .045);
|
||||
--panel-hi: rgba(255, 255, 255, .07);
|
||||
--panel-dim: rgba(0, 0, 0, .30);
|
||||
--line: rgba(255, 255, 255, .09);
|
||||
--line-soft: rgba(255, 255, 255, .06);
|
||||
--bg: #0e1013; /* 页面底 */
|
||||
--bg-elev: #14171b; /* 顶栏 / 表头等抬升面 */
|
||||
--panel: #16191e; /* 卡片 */
|
||||
--panel-hi: #1d2128; /* 卡片内交互面(按钮、分段控件) */
|
||||
--panel-dim: #101318; /* 输入框 / 代码块 */
|
||||
|
||||
--line: #262b33; /* 常规分隔线 */
|
||||
--line-soft: #1f242b; /* 表格行分隔线 */
|
||||
|
||||
/* 文本 */
|
||||
--text: #e8edf7;
|
||||
--sub: #8b9bb4;
|
||||
--dim: #64748b;
|
||||
--text: #e4e7eb;
|
||||
--sub: #98a1ac;
|
||||
--dim: #6b7480;
|
||||
|
||||
/* 主色板(与大屏 ECharts PALETTE 同源) */
|
||||
--cyan: #22d3ee;
|
||||
--violet: #a78bfa;
|
||||
--amber: #fbbf24;
|
||||
--green: #34d399;
|
||||
--red: #f87171;
|
||||
--blue: #60a5fa;
|
||||
--pink: #f472b6;
|
||||
/* 强调色与语义色(低饱和,只做状态区分,不做视觉噪音) */
|
||||
--accent: #4c8df6;
|
||||
--accent-dim: rgba(76, 141, 246, .14);
|
||||
--cyan: #4aa8c0;
|
||||
--violet: #8b7ec8;
|
||||
--amber: #c9952f;
|
||||
--green: #4ba97b;
|
||||
--red: #cf6679;
|
||||
--blue: #4c8df6;
|
||||
--pink: #c07a9e;
|
||||
|
||||
/* 语义色(用量口径:升用琥珀、降用青,刻意不用红绿) */
|
||||
/* 语义色(用量口径:升用琥珀、降用蓝,刻意不用红绿) */
|
||||
--up: var(--amber);
|
||||
--down: var(--cyan);
|
||||
--down: var(--accent);
|
||||
|
||||
/* 形状与节奏 */
|
||||
--r-card: 14px;
|
||||
--r-ctl: 9px;
|
||||
--r-pill: 6px;
|
||||
--r-card: 8px;
|
||||
--r-ctl: 6px;
|
||||
--r-pill: 4px;
|
||||
--gap: 16px;
|
||||
--shadow: 0 12px 34px rgba(0, 0, 0, .42);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -44,10 +52,7 @@
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1100px 600px at 12% -10%, rgba(34, 211, 238, .10), transparent 60%),
|
||||
radial-gradient(900px 500px at 100% 0%, rgba(167, 139, 250, .10), transparent 55%),
|
||||
var(--bg);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13.5px/1.65 "Microsoft YaHei", "PingFang SC", system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
@@ -55,7 +60,7 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--cyan); text-decoration: none; transition: color .15s; }
|
||||
a { color: var(--accent); text-decoration: none; transition: color .15s; }
|
||||
a:hover { text-decoration: underline; }
|
||||
/* 表单控件必须显式继承字体:曾经写过 `font: 13px/1.5 inherit`,
|
||||
那是非法声明(简写里不能出现 inherit 作为字族),整条被浏览器丢弃,
|
||||
@@ -63,9 +68,9 @@ a:hover { text-decoration: underline; }
|
||||
button, input, select, textarea { font-family: inherit; font-size: 13px; }
|
||||
|
||||
code {
|
||||
background: rgba(255, 255, 255, .07); padding: 1px 5px; border-radius: 4px;
|
||||
background: var(--panel-hi); padding: 1px 5px; border-radius: 3px;
|
||||
font: 12px/1.5 ui-monospace, Consolas, "Cascadia Mono", monospace;
|
||||
color: var(--cyan);
|
||||
color: var(--text);
|
||||
}
|
||||
.mono, .logbox, .fullprompt {
|
||||
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
|
||||
@@ -77,47 +82,43 @@ code {
|
||||
.muted { color: var(--sub); }
|
||||
.right { text-align: right; }
|
||||
/* 页面标题里嵌的关键数字(如总积分),给一点强调但不喧宾夺主 */
|
||||
.hl { color: var(--cyan); font-variant-numeric: tabular-nums; }
|
||||
.hl { color: var(--accent); font-variant-numeric: tabular-nums; }
|
||||
/* 0 积分的记录整行数字变暗,扫一眼就能跳过长尾 */
|
||||
.tbl td.zero { color: var(--dim); }
|
||||
|
||||
/* 可访问性:键盘焦点一定要看得见 */
|
||||
:focus-visible { outline: 2px solid rgba(34, 211, 238, .65); outline-offset: 2px; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---------------- 顶栏 ---------------- */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 0 22px; height: 58px;
|
||||
background: rgba(10, 16, 28, .86);
|
||||
padding: 0 22px; height: 56px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; letter-spacing: .2px; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; }
|
||||
.brand a { color: var(--text); }
|
||||
.brand a:hover { text-decoration: none; color: var(--cyan); }
|
||||
.brand a:hover { text-decoration: none; color: var(--accent); }
|
||||
.brand .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; background: var(--cyan);
|
||||
box-shadow: 0 0 12px var(--cyan); flex: 0 0 auto;
|
||||
width: 8px; height: 8px; border-radius: 2px; background: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand .ver { color: var(--dim); font-size: 11px; }
|
||||
|
||||
.topbar nav { display: flex; gap: 3px; margin-left: 6px; overflow-x: auto; }
|
||||
.topbar nav { display: flex; gap: 2px; margin-left: 6px; overflow-x: auto; }
|
||||
.topbar nav a {
|
||||
padding: 6px 13px; border-radius: var(--r-ctl); color: var(--sub);
|
||||
font-size: 13px; white-space: nowrap; position: relative;
|
||||
padding: 6px 12px; border-radius: var(--r-ctl); color: var(--sub);
|
||||
font-size: 13px; white-space: nowrap;
|
||||
}
|
||||
.topbar nav a:hover { background: var(--panel-hi); color: var(--text); text-decoration: none; }
|
||||
.topbar nav a.on {
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, .22), rgba(167, 139, 250, .22));
|
||||
color: #fff; font-weight: 600;
|
||||
}
|
||||
.topbar nav a.on { background: var(--accent-dim); color: var(--text); font-weight: 600; }
|
||||
.topbar .me { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
|
||||
.topbar .who { color: var(--sub); font-size: 12.5px; }
|
||||
.topbar .who b { color: var(--text); font-weight: 600; }
|
||||
/* 用户名同时是「个人中心」入口,所以是 <a>:去掉下划线并给悬浮反馈 */
|
||||
.topbar .who { display: flex; align-items: center; gap: 6px; text-decoration: none; }
|
||||
.topbar .who:hover, .topbar .who:hover b { color: var(--cyan); }
|
||||
.topbar .who:hover, .topbar .who:hover b { color: var(--accent); }
|
||||
|
||||
/* ---------------- 布局 ---------------- */
|
||||
.wrap { max-width: 1480px; margin: 0 auto; padding: 20px 22px 60px; }
|
||||
@@ -126,14 +127,9 @@ code {
|
||||
gap: 14px; flex-wrap: wrap; margin-bottom: var(--gap);
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 20px; margin: 0; font-weight: 700; letter-spacing: .3px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 19px; margin: 0; font-weight: 600; color: var(--text);
|
||||
}
|
||||
.pagehead h1::before {
|
||||
content: ""; width: 4px; height: 19px; border-radius: 2px;
|
||||
background: linear-gradient(180deg, var(--cyan), var(--violet)); flex: 0 0 auto;
|
||||
}
|
||||
.pagehead .lead { color: var(--sub); font-size: 12.5px; margin: 4px 0 0 14px; }
|
||||
.pagehead .lead { color: var(--sub); font-size: 12.5px; margin: 5px 0 0; }
|
||||
.pagehead .actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); margin-bottom: var(--gap); }
|
||||
.grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--gap); margin-bottom: var(--gap); }
|
||||
@@ -145,14 +141,9 @@ code {
|
||||
padding: 16px 18px; margin-bottom: var(--gap);
|
||||
}
|
||||
.card > h2, .card > .cardhead > h2 {
|
||||
font-size: 14px; margin: 0 0 12px; font-weight: 600; letter-spacing: .2px;
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
font-size: 13.5px; margin: 0 0 12px; font-weight: 600; color: var(--text);
|
||||
}
|
||||
.card > .cardhead > h2 { margin-bottom: 0; }
|
||||
.card > h2::before, .card > .cardhead > h2::before {
|
||||
content: ""; width: 3px; height: 13px; border-radius: 2px;
|
||||
background: var(--cyan); flex: 0 0 auto;
|
||||
}
|
||||
.cardhead {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
|
||||
@@ -161,7 +152,7 @@ code {
|
||||
.card h3 { font-size: 13px; margin: 18px 0 10px; font-weight: 600; color: var(--text); }
|
||||
.card h3:first-child { margin-top: 0; }
|
||||
.hint { color: var(--sub); font-size: 12px; line-height: 1.75; }
|
||||
.hint a { text-decoration: underline; text-decoration-color: rgba(34, 211, 238, .4); }
|
||||
.hint a { text-decoration: underline; }
|
||||
.card > .hint:last-child { margin-bottom: 0; }
|
||||
.sect-divider { border: 0; border-top: 1px solid var(--line); margin: 18px 0; }
|
||||
|
||||
@@ -172,18 +163,21 @@ code {
|
||||
}
|
||||
.kpi {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-card);
|
||||
padding: 14px 16px; position: relative; overflow: hidden;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.kpi::after {
|
||||
content: ""; position: absolute; left: 0; top: 0; width: 3px; height: 100%;
|
||||
background: var(--c, var(--cyan));
|
||||
/* --c 由模板行内给出:只作为一个 8px 的状态点,不做整块着色 */
|
||||
.kpi > span {
|
||||
color: var(--sub); font-size: 12px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.kpi > span::before {
|
||||
content: ""; width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto;
|
||||
background: var(--c, var(--line));
|
||||
}
|
||||
.kpi > span { color: var(--sub); font-size: 12px; display: block; letter-spacing: .2px; }
|
||||
.kpi > b {
|
||||
display: block; font-size: 23px; margin: 7px 0 4px; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: .3px;
|
||||
display: block; font-size: 22px; margin: 8px 0 4px; font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.kpi > i { color: var(--sub); font-size: 11.5px; font-style: normal; display: block; }
|
||||
.kpi > i { color: var(--sub); font-size: 11.5px; font-style: normal; display: block; line-height: 1.6; }
|
||||
.kpi > i .delta { color: var(--up); font-variant-numeric: tabular-nums; }
|
||||
.kpi > i .delta.dn { color: var(--down); }
|
||||
|
||||
@@ -191,7 +185,7 @@ code {
|
||||
/* 表格必须包在 .tablewrap 里:窄屏时横向滚动,而不是把卡片撑破 */
|
||||
.tablewrap { overflow-x: auto; margin: 0 -2px; }
|
||||
.tablewrap::-webkit-scrollbar { height: 8px; }
|
||||
.tablewrap::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .14); border-radius: 4px; }
|
||||
.tablewrap::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.tbl th, .tbl td {
|
||||
padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--line-soft);
|
||||
@@ -199,18 +193,17 @@ code {
|
||||
}
|
||||
.tbl th {
|
||||
color: var(--sub); font-weight: 500; font-size: 11.5px;
|
||||
white-space: nowrap; letter-spacing: .3px;
|
||||
position: sticky; top: 0; background: rgba(12, 18, 32, .96);
|
||||
backdrop-filter: blur(4px); z-index: 1;
|
||||
white-space: nowrap;
|
||||
position: sticky; top: 0; background: var(--bg-elev); z-index: 1;
|
||||
}
|
||||
.tbl tbody tr:hover { background: rgba(255, 255, 255, .028); }
|
||||
.tbl tbody tr:hover { background: var(--panel-hi); }
|
||||
.tbl tbody tr:last-child td { border-bottom: 0; }
|
||||
.tbl td.num, .tbl th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.tbl td.empty, .tbl td.empty:hover { text-align: center; color: var(--sub); padding: 28px; }
|
||||
.tbl td.empty, .tbl td.empty:hover { text-align: center; color: var(--sub); padding: 28px; background: none; }
|
||||
.tbl td.px { max-width: 460px; min-width: 220px; }
|
||||
.tbl details summary { cursor: pointer; color: var(--sub); }
|
||||
.tbl details summary:hover { color: var(--text); }
|
||||
.tbl details[open] summary { color: var(--cyan); }
|
||||
.tbl details[open] summary { color: var(--text); }
|
||||
|
||||
.scroll-y { max-height: 340px; overflow: auto; }
|
||||
|
||||
@@ -223,7 +216,7 @@ code {
|
||||
|
||||
.fullprompt {
|
||||
margin-top: 9px; padding: 11px 13px; background: var(--panel-dim);
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||
white-space: pre-wrap; word-break: break-word; color: var(--text);
|
||||
font-size: 12px; line-height: 1.75; max-height: 320px; overflow: auto;
|
||||
}
|
||||
@@ -232,14 +225,14 @@ code {
|
||||
.tag {
|
||||
display: inline-block; padding: 1px 7px; border-radius: var(--r-pill);
|
||||
font-size: 11px; line-height: 17px; white-space: nowrap;
|
||||
background: rgba(255, 255, 255, .08); color: var(--sub); vertical-align: 1px;
|
||||
background: var(--panel-hi); color: var(--sub); vertical-align: 1px;
|
||||
}
|
||||
.tag.ok { background: rgba(52, 211, 153, .16); color: var(--green); }
|
||||
.tag.warn { background: rgba(251, 191, 36, .16); color: var(--amber); }
|
||||
.tag.bad { background: rgba(248, 113, 113, .16); color: var(--red); }
|
||||
.tag.info { background: rgba(96, 165, 250, .16); color: var(--blue); }
|
||||
.tag.accent { background: rgba(167, 139, 250, .18); color: var(--violet); }
|
||||
.tag.mute { background: rgba(255, 255, 255, .06); color: var(--dim); }
|
||||
.tag.ok { background: rgba(75, 169, 123, .16); color: var(--green); }
|
||||
.tag.warn { background: rgba(201, 149, 47, .16); color: var(--amber); }
|
||||
.tag.bad { background: rgba(207, 102, 121, .16); color: var(--red); }
|
||||
.tag.info { background: rgba(76, 141, 246, .16); color: var(--blue); }
|
||||
.tag.accent { background: rgba(139, 126, 200, .16); color: var(--violet); }
|
||||
.tag.mute { background: var(--panel-hi); color: var(--dim); }
|
||||
|
||||
.kbd {
|
||||
display: inline-block; padding: 0 6px; border-radius: 4px;
|
||||
@@ -252,19 +245,18 @@ code {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: var(--r-ctl); border: 1px solid var(--line);
|
||||
background: var(--panel-hi); color: var(--text); font-size: 12.5px;
|
||||
cursor: pointer; transition: background .15s, border-color .15s, box-shadow .15s, filter .15s;
|
||||
cursor: pointer; transition: background .15s, border-color .15s;
|
||||
white-space: nowrap; text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: rgba(255, 255, 255, .11); text-decoration: none; }
|
||||
.btn:hover { background: #232830; text-decoration: none; }
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, .26), rgba(167, 139, 250, .26));
|
||||
border-color: rgba(34, 211, 238, .42); color: #fff; font-weight: 600;
|
||||
background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover { border-color: var(--cyan); box-shadow: 0 0 14px rgba(34, 211, 238, .22); }
|
||||
.btn.primary:hover { background: #5e99f7; border-color: #5e99f7; }
|
||||
.btn.ghost { background: transparent; color: var(--sub); }
|
||||
.btn.ghost:hover { color: var(--text); background: var(--panel-hi); }
|
||||
.btn.danger { border-color: rgba(248, 113, 113, .34); color: var(--red); background: rgba(248, 113, 113, .10); }
|
||||
.btn.danger:hover { background: rgba(248, 113, 113, .18); }
|
||||
.btn.danger { border-color: rgba(207, 102, 121, .34); color: var(--red); background: rgba(207, 102, 121, .10); }
|
||||
.btn.danger:hover { background: rgba(207, 102, 121, .18); }
|
||||
.btn.sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn[disabled], .btn[aria-disabled=true] { opacity: .5; cursor: not-allowed; }
|
||||
.btnrow { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
@@ -273,14 +265,12 @@ form .btn { margin-top: 8px; }
|
||||
/* ---------------- 表单 ---------------- */
|
||||
label { display: block; color: var(--sub); font-size: 12px; }
|
||||
label input, label select, label textarea, .inp {
|
||||
width: 100%; margin-top: 5px; padding: 8px 10px; border-radius: 8px;
|
||||
width: 100%; margin-top: 5px; padding: 8px 10px; border-radius: var(--r-ctl);
|
||||
background: var(--panel-dim); border: 1px solid var(--line); color: var(--text);
|
||||
font-size: 13px; line-height: 1.5; outline: none; transition: border-color .15s, background .15s;
|
||||
}
|
||||
label input:hover, label select:hover, label textarea:hover { border-color: rgba(255, 255, 255, .16); }
|
||||
label input:focus, label select:focus, label textarea:focus {
|
||||
border-color: var(--cyan); background: rgba(0, 0, 0, .42);
|
||||
font-size: 13px; line-height: 1.5; outline: none; transition: border-color .15s;
|
||||
}
|
||||
label input:hover, label select:hover, label textarea:hover { border-color: #333a44; }
|
||||
label input:focus, label select:focus, label textarea:focus { border-color: var(--accent); }
|
||||
label textarea {
|
||||
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12px; line-height: 1.6; resize: vertical;
|
||||
@@ -293,7 +283,7 @@ label.row.inline { display: inline-flex; margin-bottom: 0; }
|
||||
label.row.inline > span { flex: 0 0 auto; }
|
||||
label .unit { color: var(--dim); font-size: 11.5px; margin-left: 8px; flex: 0 0 auto; }
|
||||
.field-err { color: var(--red); font-size: 11.5px; margin-top: 4px; }
|
||||
select option { background: #111a2e; color: var(--text); }
|
||||
select option { background: var(--bg-elev); color: var(--text); }
|
||||
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
||||
.filters label { flex: 1 1 152px; }
|
||||
@@ -317,23 +307,17 @@ select option { background: #111a2e; color: var(--text); }
|
||||
font-size: 12.5px; cursor: pointer; transition: .15s; font-family: inherit;
|
||||
text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.seg a:hover, .seg button:hover { color: var(--text); background: rgba(255, 255, 255, .06); text-decoration: none; }
|
||||
.seg a.on, .seg button.on {
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, .28), rgba(167, 139, 250, .28));
|
||||
color: #fff; font-weight: 600;
|
||||
}
|
||||
.seg a:hover, .seg button:hover { color: var(--text); text-decoration: none; }
|
||||
.seg a.on, .seg button.on { background: var(--accent-dim); color: var(--text); font-weight: 600; }
|
||||
|
||||
/* ---------------- 分页 ---------------- */
|
||||
.pager { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; align-items: center; }
|
||||
.pager a, .pager .cur, .pager .gap {
|
||||
padding: 4px 11px; border-radius: 7px; border: 1px solid var(--line);
|
||||
padding: 4px 11px; border-radius: var(--r-ctl); border: 1px solid var(--line);
|
||||
font-size: 12px; color: var(--sub); min-width: 34px; text-align: center;
|
||||
}
|
||||
.pager a:hover { color: var(--text); border-color: rgba(34, 211, 238, .45); text-decoration: none; }
|
||||
.pager .cur {
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, .24), rgba(167, 139, 250, .24));
|
||||
color: #fff; border-color: transparent; font-weight: 600;
|
||||
}
|
||||
.pager a:hover { color: var(--text); border-color: var(--accent); text-decoration: none; }
|
||||
.pager .cur { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
|
||||
.pager .gap { border-color: transparent; color: var(--dim); }
|
||||
.pager .meta { margin-left: auto; color: var(--sub); font-size: 11.5px; }
|
||||
|
||||
@@ -342,18 +326,18 @@ select option { background: #111a2e; color: var(--text); }
|
||||
.flash {
|
||||
padding: 11px 14px; border-radius: var(--r-ctl); font-size: 12.5px;
|
||||
margin-bottom: 8px; border: 1px solid var(--line);
|
||||
background: rgba(34, 211, 238, .10); color: var(--text);
|
||||
background: var(--panel-hi); color: var(--text);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.flash.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .3); }
|
||||
.flash.warn { background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .3); }
|
||||
.flash.ok { background: rgba(52, 211, 153, .12); border-color: rgba(52, 211, 153, .3); }
|
||||
.flash.error { background: rgba(207, 102, 121, .12); border-color: rgba(207, 102, 121, .30); }
|
||||
.flash.warn { background: rgba(201, 149, 47, .12); border-color: rgba(201, 149, 47, .30); }
|
||||
.flash.ok { background: rgba(75, 169, 123, .12); border-color: rgba(75, 169, 123, .30); }
|
||||
.flash:last-child { margin-bottom: 0; }
|
||||
.logbox {
|
||||
background: rgba(0, 0, 0, .40); border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||
background: var(--panel-dim); border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||
padding: 12px 14px; max-height: 430px; overflow: auto; margin: 0;
|
||||
font-size: 11.5px; line-height: 1.75; white-space: pre-wrap;
|
||||
word-break: break-word; color: #c6d3e6;
|
||||
word-break: break-word; color: var(--sub);
|
||||
}
|
||||
.logbox:empty::before { content: "(暂无内容)"; color: var(--dim); }
|
||||
.empty-state {
|
||||
@@ -369,12 +353,12 @@ select option { background: #111a2e; color: var(--text); }
|
||||
}
|
||||
.login { width: 100%; max-width: 400px; padding: 28px 28px 22px; margin: 0; }
|
||||
.login .logo {
|
||||
width: 44px; height: 44px; border-radius: 13px; margin-bottom: 14px;
|
||||
background: linear-gradient(135deg, var(--cyan), var(--violet));
|
||||
width: 40px; height: 40px; border-radius: var(--r-ctl); margin-bottom: 14px;
|
||||
background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 800; color: #061019;
|
||||
font-size: 19px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.login h1 { font-size: 19px; margin: 0 0 5px; }
|
||||
.login h1 { font-size: 19px; margin: 0 0 5px; font-weight: 600; }
|
||||
.login .hint { margin: 0 0 20px; }
|
||||
.login label { margin-bottom: 14px; }
|
||||
.login .btn { width: 100%; padding: 10px; margin-top: 4px; }
|
||||
@@ -398,14 +382,13 @@ select option { background: #111a2e; color: var(--text); }
|
||||
border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||
background: var(--panel-dim); cursor: pointer; transition: .15s;
|
||||
}
|
||||
.capimg:hover { border-color: var(--cyan); }
|
||||
.capimg:hover { border-color: var(--accent); }
|
||||
|
||||
/* ---------------- 错误页 ---------------- */
|
||||
.errpage { text-align: center; padding: 66px 24px; }
|
||||
.errpage .code {
|
||||
font-size: 52px; font-weight: 800; line-height: 1; margin: 0 0 12px;
|
||||
background: linear-gradient(135deg, var(--cyan), var(--violet));
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
font-size: 46px; font-weight: 600; line-height: 1; margin: 0 0 14px;
|
||||
color: var(--sub); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.errpage p { color: var(--sub); margin: 0 0 20px; }
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-page-node-id="jBpPdqHNtyPDgHiurSUDlP">
|
||||
<head data-page-node-id="dVR2hep3JIj1Ezc0ZtFF6H">
|
||||
<meta charset="UTF-8" data-page-node-id="qqRbAyBRXJtuBQJ1uZTFiM">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" data-page-node-id="hnIdNU8Kp932sqqMuVMJdf">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WorkBuddy Portal · 积分消耗大屏</title>
|
||||
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' data-page-node-id="AHkV6pmcOA23nBLJEuCsK9"><circle cx='8' cy='8' r='6' fill='%2322d3ee' data-page-node-id="2GTvuDAClmE7KNpisaRV1j"/></svg>">
|
||||
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><circle cx='8' cy='8' r='6' fill='%234c8df6'/></svg>">
|
||||
<!-- 必须用**绝对**路径:本页由 /dashboard 直接吐出(无尾斜杠),
|
||||
若写成相对路径会解析成 /vendor/echarts.min.js → 404,结果整页图表全白。
|
||||
静态目录挂载在 /static(见 create_app 的 static_url_path)。 -->
|
||||
@@ -15,135 +15,133 @@
|
||||
明细按窗口裁剪下发(且不含 prompt 全文),页面不再整包搬运整个存档。
|
||||
详见页面脚本里的 loadSources() / refetch()。 -->
|
||||
<style>
|
||||
/* 与后台 app.css 共用同一套令牌:中性灰阶 + 单一强调色,不用渐变与辉光 */
|
||||
:root{
|
||||
--bg:#080d1a; --panel:rgba(255,255,255,.045); --line:rgba(255,255,255,.09);
|
||||
--text:#e8edf7; --sub:#8b9bb4; --cyan:#22d3ee; --violet:#a78bfa;
|
||||
--amber:#fbbf24; --green:#34d399; --pink:#f472b6; --blue:#60a5fa;
|
||||
--bg:#0e1013; --bg-elev:#14171b; --panel:#16191e; --panel-hi:#1d2128; --panel-dim:#101318;
|
||||
--line:#262b33;
|
||||
--text:#e4e7eb; --sub:#98a1ac; --dim:#6b7480;
|
||||
--accent:#4c8df6; --accent-dim:rgba(76,141,246,.14);
|
||||
--cyan:#4aa8c0; --violet:#8b7ec8; --amber:#c9952f; --green:#4ba97b;
|
||||
--pink:#c07a9e; --blue:#4c8df6; --red:#cf6679;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{
|
||||
background:
|
||||
radial-gradient(1100px 600px at 12% -10%, rgba(34,211,238,.13), transparent 60%),
|
||||
radial-gradient(900px 500px at 100% 0%, rgba(167,139,250,.13), transparent 55%),
|
||||
var(--bg);
|
||||
background:var(--bg);
|
||||
color:var(--text); font-family:"Microsoft YaHei","PingFang SC",system-ui,-apple-system,sans-serif;
|
||||
min-height:100vh; padding:18px 22px 34px;
|
||||
}
|
||||
header{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;margin-bottom:16px}
|
||||
h1{font-size:24px;font-weight:700;letter-spacing:.5px;display:flex;align-items:center;gap:10px}
|
||||
h1::before{content:"";width:5px;height:24px;border-radius:3px;background:linear-gradient(180deg,var(--cyan),var(--violet))}
|
||||
.sub{color:var(--sub);font-size:12.5px;margin-top:4px}
|
||||
.sub code{background:rgba(255,255,255,.07);padding:1px 5px;border-radius:4px;color:var(--cyan);font-size:11.5px}
|
||||
h1{font-size:20px;font-weight:600}
|
||||
.sub{color:var(--sub);font-size:12.5px;margin-top:5px}
|
||||
.sub code{background:var(--panel-hi);padding:1px 5px;border-radius:3px;color:var(--text);font-size:11.5px}
|
||||
/* 大屏是独立静态页,拿不到 Jinja 的导航,所以自己提供回后台的入口 */
|
||||
.navback{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
.abtn{
|
||||
display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:9px;
|
||||
border:1px solid var(--line);background:rgba(255,255,255,.05);color:var(--sub);
|
||||
display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:6px;
|
||||
border:1px solid var(--line);background:var(--panel-hi);color:var(--sub);
|
||||
font-size:12px;text-decoration:none;transition:.15s;
|
||||
}
|
||||
.abtn:hover{color:var(--text);border-color:rgba(34,211,238,.45);background:rgba(255,255,255,.09);text-decoration:none}
|
||||
.abtn:hover{color:var(--text);border-color:var(--accent);background:#232830;text-decoration:none}
|
||||
.meta{color:var(--sub);font-size:12px;text-align:right;line-height:1.7}
|
||||
.meta b{color:var(--cyan);font-weight:600}
|
||||
.meta b{color:var(--text);font-weight:600}
|
||||
|
||||
.bar{
|
||||
display:flex;gap:14px;flex-wrap:wrap;align-items:center;
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:12px;
|
||||
padding:12px 16px;margin-bottom:16px;backdrop-filter:blur(6px);
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
||||
padding:12px 16px;margin-bottom:16px;
|
||||
}
|
||||
.grp{display:flex;align-items:center;gap:8px}
|
||||
.grp>span{color:var(--sub);font-size:12px;white-space:nowrap}
|
||||
.seg{display:flex;background:rgba(255,255,255,.05);border:1px solid var(--line);border-radius:9px;overflow:hidden}
|
||||
.seg{display:flex;background:var(--panel-hi);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
||||
.seg button{
|
||||
background:transparent;border:0;color:var(--sub);padding:6px 13px;font-size:12.5px;
|
||||
cursor:pointer;transition:.15s;font-family:inherit;
|
||||
}
|
||||
.seg button:hover{color:var(--text);background:rgba(255,255,255,.06)}
|
||||
.seg button.on{background:linear-gradient(135deg,rgba(34,211,238,.28),rgba(167,139,250,.28));color:#fff;font-weight:600}
|
||||
.seg button:hover{color:var(--text)}
|
||||
.seg button.on{background:var(--accent-dim);color:var(--text);font-weight:600}
|
||||
select,input[type=date]{
|
||||
background:rgba(255,255,255,.06);border:1px solid var(--line);color:var(--text);
|
||||
border-radius:8px;padding:6px 10px;font-size:12.5px;font-family:inherit;outline:none;
|
||||
background:var(--panel-dim);border:1px solid var(--line);color:var(--text);
|
||||
border-radius:6px;padding:6px 10px;font-size:12.5px;font-family:inherit;outline:none;
|
||||
}
|
||||
select option{background:#111a2e;color:var(--text)}
|
||||
select option{background:var(--bg-elev);color:var(--text)}
|
||||
button.act{
|
||||
background:linear-gradient(135deg,rgba(34,211,238,.22),rgba(167,139,250,.22));
|
||||
border:1px solid rgba(34,211,238,.35);color:#fff;border-radius:9px;
|
||||
padding:6px 14px;font-size:12.5px;cursor:pointer;font-family:inherit;transition:.15s;
|
||||
background:var(--accent);border:1px solid var(--accent);color:#fff;border-radius:6px;
|
||||
padding:6px 14px;font-size:12.5px;cursor:pointer;font-family:inherit;font-weight:600;transition:.15s;
|
||||
}
|
||||
button.act:hover{border-color:var(--cyan);box-shadow:0 0 14px rgba(34,211,238,.25)}
|
||||
button.act:hover{background:#5e99f7;border-color:#5e99f7}
|
||||
|
||||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:16px}
|
||||
.kpi{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:15px 17px;position:relative;overflow:hidden;
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px 17px;
|
||||
}
|
||||
.kpi::after{content:"";position:absolute;left:0;top:0;width:3px;height:100%;background:var(--c,var(--cyan))}
|
||||
.kpi .lb{color:var(--sub);font-size:12px;letter-spacing:.3px}
|
||||
.kpi .vl{font-size:26px;font-weight:700;margin-top:7px;font-variant-numeric:tabular-nums;letter-spacing:.5px}
|
||||
.kpi .ex{color:var(--sub);font-size:11.5px;margin-top:5px}
|
||||
.kpi .lb{color:var(--sub);font-size:12px;display:flex;align-items:center;gap:6px}
|
||||
.kpi .lb::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--c,var(--line));flex:0 0 auto}
|
||||
.kpi .vl{font-size:25px;font-weight:600;margin-top:8px;font-variant-numeric:tabular-nums}
|
||||
.kpi .ex{color:var(--sub);font-size:11.5px;margin-top:5px;line-height:1.6}
|
||||
|
||||
.grid{display:grid;grid-template-columns:1.45fr 1fr;gap:16px}
|
||||
.grid.b{grid-template-columns:1fr 1fr;margin-top:16px}
|
||||
.card{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px 10px;
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px 10px;
|
||||
}
|
||||
.card h2{font-size:14px;font-weight:600;margin-bottom:2px;display:flex;align-items:center;gap:8px}
|
||||
.card h2::before{content:"";width:3px;height:13px;border-radius:2px;background:var(--cyan)}
|
||||
.card .hint{color:var(--sub);font-size:11.5px;margin-bottom:6px}
|
||||
.card h2{font-size:13.5px;font-weight:600;color:var(--text)}
|
||||
.card .hint{color:var(--sub);font-size:11.5px;margin-bottom:6px;margin-top:2px}
|
||||
.chart{width:100%;height:290px}
|
||||
.chart.tall{height:330px}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:6px}
|
||||
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid rgba(255,255,255,.06)}
|
||||
th{color:var(--sub);font-weight:500;font-size:11.5px;letter-spacing:.4px}
|
||||
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--line)}
|
||||
th{color:var(--sub);font-weight:500;font-size:11.5px}
|
||||
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tbody tr:hover{background:rgba(255,255,255,.04)}
|
||||
tbody tr:hover{background:var(--panel-hi)}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:7px;vertical-align:middle}
|
||||
|
||||
#err{
|
||||
display:none;background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.4);
|
||||
color:#fca5a5;border-radius:12px;padding:14px 16px;margin-bottom:16px;font-size:13px;line-height:1.8;
|
||||
display:none;background:rgba(207,102,121,.12);border:1px solid rgba(207,102,121,.4);
|
||||
color:#e8b6bf;border-radius:8px;padding:14px 16px;margin-bottom:16px;font-size:13px;line-height:1.8;
|
||||
}
|
||||
#err code{background:rgba(0,0,0,.35);padding:2px 6px;border-radius:4px;color:#fde68a}
|
||||
.foot{color:var(--sub);font-size:11.5px;text-align:center;margin-top:22px}
|
||||
#err code{background:var(--panel-dim);padding:2px 6px;border-radius:3px;color:var(--text)}
|
||||
.foot{color:var(--dim);font-size:11.5px;text-align:center;margin-top:22px;line-height:1.9}
|
||||
|
||||
/* ---- 消耗日历(日志视图) ---- */
|
||||
.calcard{margin-top:16px;padding-bottom:14px}
|
||||
.calhead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||
.calnav{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.caltitle{min-width:92px;text-align:center;font-size:13px;font-weight:600;color:var(--text)}
|
||||
button.act.ghost{background:rgba(255,255,255,.05);border-color:var(--line);color:var(--sub);padding:5px 11px}
|
||||
button.act.ghost:hover{color:var(--text);border-color:rgba(34,211,238,.5);box-shadow:none}
|
||||
button.act.ghost{background:var(--panel-hi);border-color:var(--line);color:var(--sub);padding:5px 11px;font-weight:400}
|
||||
button.act.ghost:hover{color:var(--text);border-color:var(--accent);background:#232830}
|
||||
button.act.ghost:disabled{opacity:.32;cursor:not-allowed}
|
||||
button.act.ghost:disabled:hover{border-color:var(--line);color:var(--sub)}
|
||||
button.act.ghost:disabled:hover{border-color:var(--line);color:var(--sub);background:var(--panel-hi)}
|
||||
.callegend{display:flex;align-items:center;justify-content:flex-end;gap:5px;color:var(--sub);font-size:11px;margin:4px 0 9px}
|
||||
.callegend i{display:inline-block;width:17px;height:11px;border-radius:3px;border:1px solid rgba(255,255,255,.10)}
|
||||
.callegend i{display:inline-block;width:17px;height:11px;border-radius:3px;border:1px solid var(--line)}
|
||||
.caldow{display:grid;grid-template-columns:repeat(7,1fr);gap:8px;text-align:center;color:var(--sub);
|
||||
font-size:11px;letter-spacing:.6px;padding-bottom:7px}
|
||||
font-size:11px;padding-bottom:7px}
|
||||
.calgrid{display:grid;grid-template-columns:repeat(7,1fr);gap:8px}
|
||||
.calcell{position:relative;min-height:72px;border-radius:10px;border:1px solid rgba(255,255,255,.07);
|
||||
background:rgba(255,255,255,.03);padding:7px 9px 10px;display:flex;flex-direction:column;gap:2px;
|
||||
overflow:hidden;transition:transform .15s,border-color .15s,box-shadow .15s}
|
||||
.calcell{position:relative;min-height:72px;border-radius:6px;border:1px solid var(--line);
|
||||
background:var(--panel);padding:7px 9px 10px;display:flex;flex-direction:column;gap:2px;
|
||||
overflow:hidden;transition:border-color .15s,background .15s}
|
||||
.calcell.void{background:transparent;border-color:transparent}
|
||||
.calcell.in{border-color:rgba(34,211,238,.42)}
|
||||
.calcell.in{border-color:var(--accent)}
|
||||
.calcell.has{cursor:pointer}
|
||||
.calcell.has:hover{transform:translateY(-2px);border-color:rgba(34,211,238,.7);box-shadow:0 8px 20px rgba(0,0,0,.38)}
|
||||
.calcell.has:hover{border-color:var(--accent);background:var(--panel-hi)}
|
||||
.calcell .cd{font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||
.calcell.today .cd{color:var(--cyan);font-weight:700}
|
||||
.calcell .cv{font-size:14px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.2px}
|
||||
.calcell.empty .cv{color:var(--sub);font-weight:500;font-size:13px}
|
||||
.calcell.today .cd{color:var(--text);font-weight:700}
|
||||
.calcell .cv{font-size:14px;font-weight:600;font-variant-numeric:tabular-nums}
|
||||
.calcell.empty .cv{color:var(--sub);font-weight:400;font-size:13px}
|
||||
.calcell .cc{font-size:10.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||
.calcell .cbar{position:absolute;left:0;bottom:0;width:100%;height:3px;background:rgba(255,255,255,.05)}
|
||||
.calcell .cbar>i{display:block;height:100%;width:0;border-radius:0 2px 0 0}
|
||||
.calcell.sel{box-shadow:inset 0 0 0 1.5px var(--cyan)}
|
||||
.calcell.peak::after{content:"峰";position:absolute;right:6px;top:5px;background:var(--amber);color:#0a1120;
|
||||
font-size:9.5px;line-height:14px;padding:0 5px;border-radius:5px;font-weight:700}
|
||||
.calcell .cbar{position:absolute;left:0;bottom:0;width:100%;height:3px;background:var(--panel-hi)}
|
||||
.calcell .cbar>i{display:block;height:100%;width:0}
|
||||
.calcell.sel{box-shadow:inset 0 0 0 1.5px var(--accent)}
|
||||
.calcell.peak::after{content:"峰";position:absolute;right:6px;top:5px;background:var(--amber);color:#0e1013;
|
||||
font-size:9.5px;line-height:14px;padding:0 5px;border-radius:3px;font-weight:700}
|
||||
.calcell.partial .cc::after{content:" · 残";color:var(--amber)}
|
||||
.calstat{display:flex;gap:22px;flex-wrap:wrap;margin-top:12px;padding-top:12px;border-top:1px solid var(--line);
|
||||
font-size:12px;color:var(--sub)}
|
||||
.calstat b{color:var(--text);margin-left:6px;font-variant-numeric:tabular-nums}
|
||||
#calTip{position:fixed;z-index:99;pointer-events:none;display:none;max-width:290px;
|
||||
background:rgba(12,18,32,.96);border:1px solid rgba(255,255,255,.14);border-radius:9px;
|
||||
padding:9px 12px;font-size:12px;line-height:1.8;color:var(--text);box-shadow:0 12px 34px rgba(0,0,0,.5)}
|
||||
#calTip b{color:var(--cyan)}
|
||||
background:var(--bg-elev);border:1px solid var(--line);border-radius:6px;
|
||||
padding:9px 12px;font-size:12px;line-height:1.8;color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.5)}
|
||||
#calTip b{color:var(--text)}
|
||||
@media(max-width:820px){
|
||||
.calcell{min-height:58px;padding:5px 6px 8px}
|
||||
.calcell .cv{font-size:12px}.calcell .cc{font-size:9.5px}
|
||||
@@ -151,15 +149,15 @@
|
||||
@media(max-width:1180px){.grid,.grid.b{grid-template-columns:1fr}}
|
||||
|
||||
/* ---- 环比 / 标签 / 迷你条 ---- */
|
||||
.dimup{color:var(--amber)} /* 用量上升用琥珀,下降用青色:刻意不用红绿 */
|
||||
.dimdn{color:var(--cyan)}
|
||||
.ok{color:var(--green)} .bad{color:#f87171}
|
||||
.tag{display:inline-block;font-size:10px;line-height:15px;padding:0 5px;border-radius:4px;
|
||||
background:rgba(251,191,36,.18);color:var(--amber);margin-left:6px;vertical-align:1px;white-space:nowrap}
|
||||
.tag.free{background:rgba(96,165,250,.16);color:var(--blue);margin-left:4px}
|
||||
.bar-mini{height:7px;border-radius:4px;background:rgba(255,255,255,.08);overflow:hidden}
|
||||
.bar-mini>div{height:100%;border-radius:4px;background:linear-gradient(90deg,#22d3ee,#a78bfa)}
|
||||
.card .hint b{color:var(--cyan);font-weight:600}
|
||||
.dimup{color:var(--amber)} /* 用量上升用琥珀,下降用蓝:刻意不用红绿 */
|
||||
.dimdn{color:var(--accent)}
|
||||
.ok{color:var(--green)} .bad{color:var(--red)}
|
||||
.tag{display:inline-block;font-size:10px;line-height:15px;padding:0 5px;border-radius:3px;
|
||||
background:rgba(201,149,47,.18);color:var(--amber);margin-left:6px;vertical-align:1px;white-space:nowrap}
|
||||
.tag.free{background:rgba(76,141,246,.16);color:var(--blue);margin-left:4px}
|
||||
.bar-mini{height:6px;border-radius:3px;background:var(--panel-hi);overflow:hidden}
|
||||
.bar-mini>div{height:100%;border-radius:3px;background:var(--accent)}
|
||||
.card .hint b{color:var(--text);font-weight:600}
|
||||
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
||||
|
||||
/* ---- TOP 榜 / 数据源 ---- */
|
||||
@@ -172,21 +170,20 @@
|
||||
#tblTop td:nth-child(-n+5){white-space:nowrap}
|
||||
#tblTop td.px{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
color:var(--sub);font-size:11.5px}
|
||||
#tblSrc code{background:rgba(255,255,255,.06);padding:1px 5px;border-radius:4px;
|
||||
font-size:11px;color:var(--cyan);font-family:Consolas,Monaco,monospace}
|
||||
#tblSrc code{background:var(--panel-hi);padding:1px 5px;border-radius:3px;
|
||||
font-size:11px;color:var(--text);font-family:Consolas,Monaco,monospace}
|
||||
#srcCheck{margin-top:10px;font-size:11.5px;color:var(--sub);line-height:1.95;
|
||||
border-top:1px solid var(--line);padding-top:9px}
|
||||
.sub code{background:rgba(255,255,255,.06);padding:1px 5px;border-radius:4px;color:var(--cyan);
|
||||
.sub code{background:var(--panel-hi);padding:1px 5px;border-radius:3px;color:var(--text);
|
||||
font-family:Consolas,Monaco,monospace;font-size:11.5px}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body data-page-node-id="cgE383OQXYMooNAAYOf8Xo">
|
||||
<body>
|
||||
|
||||
<header data-page-node-id="qNmHyKNLVV83WavBjcUDvZ">
|
||||
<div data-page-node-id="nvuOyMOnmQg04RZg7ABko0">
|
||||
<h1 data-page-node-id="bbxwehmAM7hCbojvIUbEn5">WorkBuddy Portal · 积分消耗大屏</h1>
|
||||
<div class="sub" data-page-node-id="LgqD86gtDlqqDDFruEcboi">数据来源:本项目 <code>/api</code>(SQLite 正本 <code>data/usage.sqlite</code>),页面按筛选条件实时聚合</div>
|
||||
<header>
|
||||
<div>
|
||||
<h1>WorkBuddy Portal · 积分消耗大屏</h1>
|
||||
<div class="sub">数据来源:<code>/api</code>(正本 <code>data/usage.sqlite</code>)</div>
|
||||
<div class="navback">
|
||||
<a class="abtn" href="/">← 返回后台</a>
|
||||
<a class="abtn" href="/records">数据明细</a>
|
||||
@@ -195,161 +192,160 @@
|
||||
<a class="abtn" id="abtnLogs" href="/logs" hidden>日志管理</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta" data-page-node-id="4jmZuGBGC8JQmVAE2IOJ7H">
|
||||
数据范围 <b id="mRange" data-page-node-id="Hv9sHK3bEHKC2oi5QrSBHx">-</b><br data-page-node-id="loxYsApIQ9pIhv0bpA4S7c">
|
||||
共 <b id="mDays" data-page-node-id="j7DQYi30ic8FPEQH2mfPSW">-</b> 天 · <b id="mRows" data-page-node-id="I5ZLhC3ODlkutlGRaRlRKp">-</b> 条请求记录<br data-page-node-id="ecCETqYdR2yF04jFxFPiT8">
|
||||
<div class="meta">
|
||||
数据范围 <b id="mRange">-</b><br>
|
||||
共 <b id="mDays">-</b> 天 · <b id="mRows">-</b> 条请求记录<br>
|
||||
数据快照 <b id="mUpdate">-</b>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="err" data-page-node-id="dj4W2yF1bo2dSOqXftYHYb"></div>
|
||||
<div id="err"></div>
|
||||
|
||||
<div class="bar" data-page-node-id="DdktDg1IO90mtTF9ZuSNB9">
|
||||
<div class="grp" data-page-node-id="f4G2ZTZxKEJdVG5RkYwL2Q">
|
||||
<span data-page-node-id="6lFVYOo1P7geY0oSXMscUj">日期范围</span>
|
||||
<div class="seg" id="segRange" data-page-node-id="ev2oBcItYEQrzl7wiXlzBG">
|
||||
<button data-v="day" data-page-node-id="8MbAF6CvYCeQHQ1LFx4L8D">今日</button>
|
||||
<button data-v="7d" class="on" data-page-node-id="hiFAf8qWEb3fE6l2MgUbKR">近 7 天</button>
|
||||
<button data-v="30d" data-page-node-id="N4I8KC6ZmmbwXFqQz4pyBy">近 30 天</button>
|
||||
<button data-v="all" data-page-node-id="esurYqQl7tJrebaUFYae80">全部</button>
|
||||
<button data-v="custom" data-page-node-id="LUr2Jzq9MLGadHwepmYg3B">自定义</button>
|
||||
<div class="bar">
|
||||
<div class="grp">
|
||||
<span>日期范围</span>
|
||||
<div class="seg" id="segRange">
|
||||
<button data-v="day">今日</button>
|
||||
<button data-v="7d" class="on">近 7 天</button>
|
||||
<button data-v="30d">近 30 天</button>
|
||||
<button data-v="all">全部</button>
|
||||
<button data-v="custom">自定义</button>
|
||||
</div>
|
||||
<select id="selDay" title="选择具体某一天" style="display:none" data-page-node-id="AXWOgamGumzhgLyFWDtO5F"></select>
|
||||
<span id="customBox" style="display:none;gap:6px;align-items:center" data-page-node-id="c6BMGtK1rXEGLtPey8eXhK">
|
||||
<input type="date" id="dFrom" data-page-node-id="NZFXC8NZfUMAKOwCeuWi8g"><span style="color:var(--sub)" data-page-node-id="c5MDTzWvPgNNSXYezC5q4d">至</span><input type="date" id="dTo" data-page-node-id="ijDpwRtLUU7VCCFK2s8ad0">
|
||||
<select id="selDay" title="选择具体某一天" style="display:none"></select>
|
||||
<span id="customBox" style="display:none;gap:6px;align-items:center">
|
||||
<input type="date" id="dFrom"><span style="color:var(--sub)">至</span><input type="date" id="dTo">
|
||||
</span>
|
||||
</div>
|
||||
<div class="grp" data-page-node-id="x0nms61tGsgUGorPYjYQnX">
|
||||
<span data-page-node-id="OWHQO4wrcghIFuTngJTrgn">统计维度</span>
|
||||
<div class="seg" id="segGran" data-page-node-id="mMRb516sRLu1kmhM4ZCXo3">
|
||||
<button data-v="auto" class="on" data-page-node-id="9z0JMKlm1sL8Na4FE6eZyX">自动</button>
|
||||
<button data-v="day" data-page-node-id="fMCwiGyoKGl1zmLVyi3fsz">按天</button>
|
||||
<button data-v="week" data-page-node-id="Hpl4cp1jpGMjejxp9zE9L4">按周</button>
|
||||
<button data-v="month" data-page-node-id="ebklBG8V2FMGuvwmlUDSDD">按月</button>
|
||||
<div class="grp">
|
||||
<span>统计维度</span>
|
||||
<div class="seg" id="segGran">
|
||||
<button data-v="auto" class="on">自动</button>
|
||||
<button data-v="day">按天</button>
|
||||
<button data-v="week">按周</button>
|
||||
<button data-v="month">按月</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grp" data-page-node-id="GNZcj0escL1zjzKbpm4wrr">
|
||||
<span data-page-node-id="qFpAW08WBrAvUR1rD9pg1r">拆分维度</span>
|
||||
<div class="seg" id="segDim" data-page-node-id="Ke7fl3DhEfgDU4XOqJA4CC">
|
||||
<button data-v="model" class="on" data-page-node-id="umLIxwwds01h4xJGMTKJ4N">模型</button>
|
||||
<button data-v="client" data-page-node-id="AmKFjL92JpfCZ34bugR90r">客户端</button>
|
||||
<button data-v="hour" data-page-node-id="PMJKA7oulDfEMrMLmYok3Q">时段</button>
|
||||
<div class="grp">
|
||||
<span>拆分维度</span>
|
||||
<div class="seg" id="segDim">
|
||||
<button data-v="model" class="on">模型</button>
|
||||
<button data-v="client">客户端</button>
|
||||
<button data-v="hour">时段</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="act" id="btnReload" title="数据由 fetch_usage.py 采集后加工成 dashboard/data/*.json,重新采集后刷新本页即可">刷新页面</button>
|
||||
<button class="act" id="btnReload" title="重新向 /api 取数并重绘本页">刷新页面</button>
|
||||
</div>
|
||||
|
||||
<div class="kpis" id="kpis" data-page-node-id="ezG8rbfBQAhGqE7X7WvjI0"></div>
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="card calcard" data-page-node-id="CalCard0001">
|
||||
<div class="calhead" data-page-node-id="CalHead0001">
|
||||
<div data-page-node-id="CalHead0002">
|
||||
<h2 data-page-node-id="CalHead0003">消耗日历 · 日志视图</h2>
|
||||
<div class="hint" id="calHint" data-page-node-id="CalHead0004">按天汇总积分消耗与调用量,色块深浅表示当日强度(相对所选月份峰值);点击有数据的日期可下钻到当日</div>
|
||||
<div class="card calcard">
|
||||
<div class="calhead">
|
||||
<div>
|
||||
<h2>消耗日历 · 日志视图</h2>
|
||||
<div class="hint" id="calHint">按天汇总积分消耗与调用量,色块深浅表示当日强度(相对所选月份峰值);点击有数据的日期可下钻到当日</div>
|
||||
</div>
|
||||
<div class="calnav" data-page-node-id="CalNav0001">
|
||||
<div class="grp" data-page-node-id="CalNav0002">
|
||||
<span data-page-node-id="CalNav0003">着色依据</span>
|
||||
<div class="seg" id="segCalMode" data-page-node-id="CalNav0004">
|
||||
<button data-v="credits" class="on" data-page-node-id="CalNav0005">积分</button>
|
||||
<button data-v="calls" data-page-node-id="CalNav0006">调用量</button>
|
||||
<div class="calnav">
|
||||
<div class="grp">
|
||||
<span>着色依据</span>
|
||||
<div class="seg" id="segCalMode">
|
||||
<button data-v="credits" class="on">积分</button>
|
||||
<button data-v="calls">调用量</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="act ghost" id="calPrev" title="上一月" data-page-node-id="CalNav0007">‹</button>
|
||||
<span class="caltitle" id="calTitle" data-page-node-id="CalNav0008">-</span>
|
||||
<button class="act ghost" id="calNext" title="下一月" data-page-node-id="CalNav0009">›</button>
|
||||
<button class="act ghost" id="calNow" title="跳到最后有数据的月份" data-page-node-id="CalNav0010">最新</button>
|
||||
<button class="act ghost" id="calPrev" title="上一月">‹</button>
|
||||
<span class="caltitle" id="calTitle">-</span>
|
||||
<button class="act ghost" id="calNext" title="下一月">›</button>
|
||||
<button class="act ghost" id="calNow" title="跳到最后有数据的月份">最新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="callegend" id="calLegend" data-page-node-id="CalLegend01"></div>
|
||||
<div class="caldow" data-page-node-id="CalDow00001">
|
||||
<span data-page-node-id="CalDow00002">一</span><span data-page-node-id="CalDow00003">二</span><span data-page-node-id="CalDow00004">三</span><span data-page-node-id="CalDow00005">四</span><span data-page-node-id="CalDow00006">五</span><span data-page-node-id="CalDow00007">六</span><span data-page-node-id="CalDow00008">日</span>
|
||||
<div class="callegend" id="calLegend"></div>
|
||||
<div class="caldow">
|
||||
<span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span>
|
||||
</div>
|
||||
<div class="calgrid" id="calGrid" data-page-node-id="CalGrid0001"></div>
|
||||
<div class="calstat" id="calStat" data-page-node-id="CalStat0001"></div>
|
||||
<div class="calgrid" id="calGrid"></div>
|
||||
<div class="calstat" id="calStat"></div>
|
||||
</div>
|
||||
<div id="calTip" data-page-node-id="CalTip0001"></div>
|
||||
<div id="calTip"></div>
|
||||
|
||||
<div class="grid" data-page-node-id="Y8L5F0bCd1duWGGhtdnfoI">
|
||||
<div class="card" data-page-node-id="9dkepoT2cxSqyqReGkIdsN">
|
||||
<h2 data-page-node-id="rWK6Yj9FegoLslsQgGDqW6">消耗趋势</h2>
|
||||
<div class="hint" id="trendHint" data-page-node-id="CmcGDzFQGuVCQPHLNjjesn">按选定统计维度展示积分消耗与调用次数</div>
|
||||
<div class="chart tall" id="cTrend" data-page-node-id="Yl7NmMfhUpKb5C0y9vw5nn"></div>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>消耗趋势</h2>
|
||||
<div class="hint" id="trendHint">按选定统计维度展示积分消耗与调用次数</div>
|
||||
<div class="chart tall" id="cTrend"></div>
|
||||
</div>
|
||||
<div class="card" data-page-node-id="MNLST8ygBBvzlhIUWesmDC">
|
||||
<div class="card">
|
||||
<div class="cardhead">
|
||||
<h2 data-page-node-id="6yGT1sOOvBuMJkwikplAeN">维度构成</h2>
|
||||
<div class="seg" id="segPieMetric" data-page-node-id="PieSeg0001">
|
||||
<button data-v="credits" class="on" data-page-node-id="PieSeg0002">按积分</button>
|
||||
<button data-v="calls" data-page-node-id="PieSeg0003">按调用量</button>
|
||||
<h2>维度构成</h2>
|
||||
<div class="seg" id="segPieMetric">
|
||||
<button data-v="credits" class="on">按积分</button>
|
||||
<button data-v="calls">按调用量</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint" id="pieHint" data-page-node-id="2mTXUjnvf1XD2ZG7LSFM9F">按拆分维度统计积分消耗占比</div>
|
||||
<div class="chart tall" id="cPie" data-page-node-id="WSjIdmCWoCmtoWgRk3rR6a"></div>
|
||||
<div class="hint" id="pieHint">按拆分维度统计积分消耗占比</div>
|
||||
<div class="chart tall" id="cPie"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid b" data-page-node-id="p5TYzrQeXYclXwXZc0fUrY">
|
||||
<div class="card" data-page-node-id="7DuEunYEvwgw3tJDDfUmRv">
|
||||
<h2 data-page-node-id="MSJH2nzGRPudHm7yBktel9">维度堆叠分布</h2>
|
||||
<div class="hint" id="stackHint" data-page-node-id="a2cMsTQF1SGPs7Z5Irwi0F">各维度在各时间段上的积分消耗堆叠</div>
|
||||
<div class="chart" id="cStack" data-page-node-id="8GgWmtBxYAkB1vzWEraxTi"></div>
|
||||
<div class="grid b">
|
||||
<div class="card">
|
||||
<h2>维度堆叠分布</h2>
|
||||
<div class="hint" id="stackHint">各维度在各时间段上的积分消耗堆叠</div>
|
||||
<div class="chart" id="cStack"></div>
|
||||
</div>
|
||||
<div class="card" data-page-node-id="GtFQNN1efZkhbOgg4nSwP0">
|
||||
<h2 data-page-node-id="0kKEgioY6RdyLLMw0E7svo">时段 × 日期热力图</h2>
|
||||
<div class="hint" id="heatHint" data-page-node-id="sXCbVdfmqJn7E4fJRJbG4f">颜色越亮表示该时段消耗越高</div>
|
||||
<div class="chart" id="cHeat" data-page-node-id="ZopZiALpWzp3dXaZPJiC7B"></div>
|
||||
<div class="card">
|
||||
<h2>时段 × 日期热力图</h2>
|
||||
<div class="hint" id="heatHint">颜色越亮表示该时段消耗越高</div>
|
||||
<div class="chart" id="cHeat"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid b" data-page-node-id="HistRow0001">
|
||||
<div class="card" data-page-node-id="HistCard001">
|
||||
<h2 data-page-node-id="HistCard002">单笔消耗分布(长尾)</h2>
|
||||
<div class="hint" id="histHint" data-page-node-id="HistCard003">按单笔积分分桶</div>
|
||||
<div class="chart" id="cHist" data-page-node-id="HistCard004"></div>
|
||||
<div class="grid b">
|
||||
<div class="card">
|
||||
<h2>单笔消耗分布(长尾)</h2>
|
||||
<div class="hint" id="histHint">按单笔积分分桶</div>
|
||||
<div class="chart" id="cHist"></div>
|
||||
</div>
|
||||
<div class="card" data-page-node-id="ScatCard001">
|
||||
<h2 data-page-node-id="ScatCard002">模型效率散点</h2>
|
||||
<div class="hint" id="scatterHint" data-page-node-id="ScatCard003">请求数 × 积分,气泡大小 = 单次均价</div>
|
||||
<div class="chart" id="cScatter" data-page-node-id="ScatCard004"></div>
|
||||
<div class="card">
|
||||
<h2>模型效率散点</h2>
|
||||
<div class="hint" id="scatterHint">请求数 × 积分,气泡大小 = 单次均价</div>
|
||||
<div class="chart" id="cScatter"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid b" data-page-node-id="AJe8K1vlk6fFa7VkHQBfk1">
|
||||
<div class="card" data-page-node-id="uXTFSuEDX2uijM5H6FkoCU">
|
||||
<h2 data-page-node-id="AuhjUdYBYmEYYT6SEh50T8">维度汇总表</h2>
|
||||
<div class="hint" data-page-node-id="fVNeKG380KfClp6qHRdP6W">按拆分维度聚合,按积分消耗降序</div>
|
||||
<div style="max-height:300px;overflow:auto" data-page-node-id="nf3LtPrwJZko43cEaHKMhx"><table id="tblDim" data-page-node-id="4E0T1dD39HCRci06VTahNY"></table></div>
|
||||
<div class="grid b">
|
||||
<div class="card">
|
||||
<h2>维度汇总表</h2>
|
||||
<div class="hint">按拆分维度聚合,按积分消耗降序</div>
|
||||
<div style="max-height:300px;overflow:auto"><table id="tblDim"></table></div>
|
||||
</div>
|
||||
<div class="card" data-page-node-id="HTGAo9nJBdjXgrdoBiSqfP">
|
||||
<h2 data-page-node-id="s0AhZQxvSQlKGY2flPkDeF">每日明细表</h2>
|
||||
<div class="hint" data-page-node-id="6CyHLq7WRzpn0PL29I7iJd">当前筛选范围内逐日汇总</div>
|
||||
<div style="max-height:300px;overflow:auto" data-page-node-id="J1mlkJ3uAd9BNXgCW09pHH"><table id="tblDay" data-page-node-id="VUcijHxm29zvPPCXC5oT7l"></table></div>
|
||||
<div class="card">
|
||||
<h2>每日明细表</h2>
|
||||
<div class="hint">当前筛选范围内逐日汇总</div>
|
||||
<div style="max-height:300px;overflow:auto"><table id="tblDay"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid b" data-page-node-id="TopRow000001">
|
||||
<div class="card" data-page-node-id="TopCard0001">
|
||||
<div class="grid b">
|
||||
<div class="card">
|
||||
<div class="cardhead">
|
||||
<h2 data-page-node-id="TopCard0002">TOP 单笔请求</h2>
|
||||
<div class="seg" id="segTopMode" data-page-node-id="TopSeg00001">
|
||||
<button data-v="range" class="on" data-page-node-id="TopSeg00002">当前筛选</button>
|
||||
<button data-v="global" data-page-node-id="TopSeg00003">全局 TOP200</button>
|
||||
<h2>TOP 单笔请求</h2>
|
||||
<div class="seg" id="segTopMode">
|
||||
<button data-v="range" class="on">当前筛选</button>
|
||||
<button data-v="global">全局 TOP200</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint" id="topHint" data-page-node-id="TopCard0003">按积分降序</div>
|
||||
<div style="max-height:320px;overflow:auto" data-page-node-id="TopCard0004"><table id="tblTop" data-page-node-id="TopCard0005"></table></div>
|
||||
<div class="hint" id="topHint">按积分降序</div>
|
||||
<div style="max-height:320px;overflow:auto"><table id="tblTop"></table></div>
|
||||
</div>
|
||||
<div class="card" data-page-node-id="SrcCard0001">
|
||||
<h2 data-page-node-id="SrcCard0002">数据源</h2>
|
||||
<div class="hint" data-page-node-id="SrcCard0003">数据全部由本项目 Flask 后端经 SQLite 实时聚合(无中间 JSON 层)</div>
|
||||
<div style="max-height:250px;overflow:auto" data-page-node-id="SrcCard0004"><table id="tblSrc" data-page-node-id="SrcCard0005"></table></div>
|
||||
<div id="srcCheck" data-page-node-id="SrcCard0006"></div>
|
||||
<div class="card">
|
||||
<h2>数据源</h2>
|
||||
<div class="hint">数据由本项目 Flask 后端从 SQLite 实时聚合</div>
|
||||
<div style="max-height:250px;overflow:auto"><table id="tblSrc"></table></div>
|
||||
<div id="srcCheck"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot" data-page-node-id="ZUGgx25CirIv3OdO9R3ywV">数据链:本项目 Flask 后端(进程内定时采集 → SQLite 正本)→ <code>/api</code> 实时聚合 → 本页渲染 ·
|
||||
筛选项变更会按窗口重新取数(明细按需下发,不含 prompt 全文)</div>
|
||||
<div class="foot">数据链:进程内定时采集 → <code>data/usage.sqlite</code> → <code>/api</code> 实时聚合 → 本页渲染</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
@@ -367,18 +363,19 @@
|
||||
*/
|
||||
const API = "/api";
|
||||
|
||||
const PALETTE = ["#22d3ee","#a78bfa","#fbbf24","#34d399","#f472b6","#60a5fa","#fb923c","#4ade80","#c084fc","#f87171"];
|
||||
// 低饱和色板:够区分 10 个维度,又不至于把大屏变成调色板
|
||||
const PALETTE = ["#4c8df6","#4aa8c0","#8b7ec8","#c9952f","#4ba97b","#c07a9e","#7d8896","#6d9fd8","#9a8f7a","#5f8ca8"];
|
||||
// 维度数可能超过色板长度(实测 12 个模型 > 10 色),超出部分按黄金角补色,避免同色歧义
|
||||
const dimColor = i => i < PALETTE.length ? PALETTE[i]
|
||||
: "hsl(" + Math.round((i * 137.508) % 360) + ",70%,62%)";
|
||||
// 日历 5 级色阶(沿用页面热力图的 青→紫→粉 渐变语义)
|
||||
: "hsl(" + Math.round((i * 137.508) % 360) + ",32%,62%)";
|
||||
// 日历 5 级色阶(与热力图同一套、由深到浅的蓝色阶)
|
||||
const CAL_LV = [
|
||||
{bg:"rgba(255,255,255,.03)", bar:"rgba(255,255,255,.07)"},
|
||||
{bg:"rgba(34,211,238,.10)", bar:"#155e75"},
|
||||
{bg:"rgba(34,211,238,.17)", bar:"#0891b2"},
|
||||
{bg:"rgba(34,211,238,.25)", bar:"#22d3ee"},
|
||||
{bg:"rgba(167,139,250,.30)", bar:"#a78bfa"},
|
||||
{bg:"rgba(244,114,182,.32)", bar:"#f472b6"}
|
||||
{bg:"transparent", bar:"#1f242b"},
|
||||
{bg:"rgba(76,141,246,.10)", bar:"#22405e"},
|
||||
{bg:"rgba(76,141,246,.17)", bar:"#2f5b86"},
|
||||
{bg:"rgba(76,141,246,.25)", bar:"#4c8df6"},
|
||||
{bg:"rgba(201,149,47,.30)", bar:"#c9952f"},
|
||||
{bg:"rgba(192,122,158,.32)", bar:"#c07a9e"}
|
||||
];
|
||||
// 单笔消耗分布的分桶(0 = 免费,>100 为极端长尾)
|
||||
const HIST_LABELS = ["0(免费)","≤0.1","≤0.5","≤1","≤2","≤5","≤10","≤20","≤50","≤100",">100"];
|
||||
@@ -695,35 +692,35 @@
|
||||
|
||||
charts.trend.setOption({
|
||||
grid:{left:52,right:56,top:44,bottom:52},
|
||||
tooltip:{trigger:"axis",backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
||||
textStyle:{color:"#e8edf7",fontSize:12},
|
||||
tooltip:{trigger:"axis",backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||
formatter:p=>{
|
||||
const i = p[0].dataIndex, o = g[i];
|
||||
return `<b>${o.key}</b>${i===partIdx?`(残日,截至 ${pd.hhmm})`:""}<br>`
|
||||
+ `积分 ${fmt(o.credits)}<br>调用 ${fmt0(o.calls)} 次`;
|
||||
}},
|
||||
legend:{data:["积分消耗","调用次数"],top:6,textStyle:{color:"#8b9bb4",fontSize:11.5},icon:"roundRect",itemWidth:11,itemHeight:11},
|
||||
xAxis:{type:"category",data:labels,axisLabel:{color:"#8b9bb4",fontSize:11,rotate:labels.length>16?38:0},
|
||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}},axisTick:{show:false}},
|
||||
legend:{data:["积分消耗","调用次数"],top:6,textStyle:{color:"#98a1ac",fontSize:11.5},icon:"roundRect",itemWidth:11,itemHeight:11},
|
||||
xAxis:{type:"category",data:labels,axisLabel:{color:"#98a1ac",fontSize:11,rotate:labels.length>16?38:0},
|
||||
axisLine:{lineStyle:{color:"#262b33"}},axisTick:{show:false}},
|
||||
yAxis:[
|
||||
{type:"value",name:"积分",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
||||
{type:"value",name:"次数",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
||||
splitLine:{show:false},axisLabel:{color:"#8b9bb4",fontSize:11}}
|
||||
{type:"value",name:"积分",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||
{type:"value",name:"次数",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||
splitLine:{show:false},axisLabel:{color:"#98a1ac",fontSize:11}}
|
||||
],
|
||||
dataZoom:labels.length>24?[{type:"slider",height:14,bottom:14,borderColor:"transparent",
|
||||
backgroundColor:"rgba(255,255,255,.05)",fillerColor:"rgba(34,211,238,.18)",
|
||||
handleStyle:{color:"#22d3ee"},textStyle:{color:"#8b9bb4",fontSize:10}}]:[],
|
||||
backgroundColor:"#1d2128",fillerColor:"rgba(76,141,246,.18)",
|
||||
handleStyle:{color:"#4c8df6"},textStyle:{color:"#98a1ac",fontSize:10}}]:[],
|
||||
series:[
|
||||
{name:"积分消耗",type:"bar",barMaxWidth:26,
|
||||
data:g.map((o,i)=> i===partIdx
|
||||
? {value:+o.credits.toFixed(2), itemStyle:{color:"rgba(34,211,238,.34)",borderColor:"rgba(251,191,36,.6)",
|
||||
? {value:+o.credits.toFixed(2), itemStyle:{color:"rgba(76,141,246,.34)",borderColor:"rgba(201,149,47,.6)",
|
||||
borderWidth:1,borderType:"dashed",borderRadius:[4,4,0,0]}}
|
||||
: +o.credits.toFixed(2)),
|
||||
itemStyle:{borderRadius:[4,4,0,0],
|
||||
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#22d3ee"},{offset:1,color:"rgba(34,211,238,.18)"}])}},
|
||||
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#4c8df6"},{offset:1,color:"rgba(76,141,246,.18)"}])}},
|
||||
{name:"调用次数",type:"line",yAxisIndex:1,smooth:true,symbol:"circle",symbolSize:5,
|
||||
data:g.map(o=>o.calls),lineStyle:{color:"#a78bfa",width:2},itemStyle:{color:"#a78bfa"}}
|
||||
data:g.map(o=>o.calls),lineStyle:{color:"#c9952f",width:2},itemStyle:{color:"#c9952f"}}
|
||||
]
|
||||
},true);
|
||||
}
|
||||
@@ -740,24 +737,24 @@
|
||||
extra:{calls:o.calls, credits:o.credits}
|
||||
}));
|
||||
if(rest.length) items.push({name:"其他 "+rest.length+" 项", value:+restV.toFixed(2),
|
||||
itemStyle:{color:"#64748b"}, extra:{calls:rest.reduce((s,o)=>s+o.calls,0),
|
||||
itemStyle:{color:"#6b7480"}, extra:{calls:rest.reduce((s,o)=>s+o.calls,0),
|
||||
credits:rest.reduce((s,o)=>s+o.credits,0)}});
|
||||
$("#pieHint").textContent = "按"+DIM_NAME+(metric==="credits"?"统计积分消耗占比":"统计调用次数占比")
|
||||
+ (rest.length ? `(TOP ${N} 之外的 ${rest.length} 项已合并为「其他」)` : "");
|
||||
charts.pie.setOption({
|
||||
tooltip:{trigger:"item",backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
||||
textStyle:{color:"#e8edf7",fontSize:12},
|
||||
tooltip:{trigger:"item",backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||
formatter:p=>{
|
||||
const e = (p.data.extra)||{};
|
||||
return `<b>${p.name}</b><br>积分 ${fmt(e.credits||0)} / 调用 ${fmt0(e.calls||0)} 次<br>`
|
||||
return `<b>${esc(p.name)}</b><br>积分 ${fmt(e.credits||0)} / 调用 ${fmt0(e.calls||0)} 次<br>`
|
||||
+ `占${metric==="credits"?"积分":"调用"} ${p.percent}%`;
|
||||
}},
|
||||
legend:{type:"scroll",bottom:2,textStyle:{color:"#8b9bb4",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||
legend:{type:"scroll",bottom:2,textStyle:{color:"#98a1ac",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||
series:[{
|
||||
type:"pie",radius:["46%","70%"],center:["50%","44%"],avoidLabelOverlap:true,
|
||||
itemStyle:{borderColor:"#0b1020",borderWidth:2},
|
||||
label:{color:"#c7d2e3",fontSize:11,formatter:"{b}\n{d}%"},
|
||||
labelLine:{lineStyle:{color:"rgba(255,255,255,.2)"}},
|
||||
itemStyle:{borderColor:"#16191e",borderWidth:2},
|
||||
label:{color:"#e4e7eb",fontSize:11,formatter:"{b}\n{d}%"},
|
||||
labelLine:{lineStyle:{color:"#3a414b"}},
|
||||
data:items
|
||||
}]
|
||||
},true);
|
||||
@@ -778,6 +775,10 @@
|
||||
const metricDim = new Map();
|
||||
rows.forEach(x=>{ const d=DIM_KEY(x); metricDim.set(d,(metricDim.get(d)||0)+x.credits); });
|
||||
const dimList = [...dims].sort((a,b)=>(metricDim.get(b)||0)-(metricDim.get(a)||0));
|
||||
// 名字保持原样存进 series:图例走 ECharts 的 canvas 渲染(是像素不是 HTML),
|
||||
// 转义反而会把 `&` 画成 `&`。真正需要防的是下面的 tooltip ——
|
||||
// ECharts 的 tooltip 默认 renderMode 是 html,内容按 HTML 插入,
|
||||
// 所以那里必须自己转义(dimList 里是 model / client 名,来自上游接口)。
|
||||
const series = dimList.map((d,i)=>({
|
||||
name:(gran==="hour" ? d : DIM_LABEL(d)), type:"bar", stack:"t", barMaxWidth:30,
|
||||
itemStyle:{color:dimColor(i)},
|
||||
@@ -787,13 +788,19 @@
|
||||
$("#stackHint").textContent = "各"+DIM_NAME+"在时间轴上的积分消耗堆叠(图例按消耗降序;无数据的时间段已补 0)";
|
||||
charts.stack.setOption({
|
||||
grid:{left:52,right:18,top:38,bottom:44},
|
||||
tooltip:{trigger:"axis",axisPointer:{type:"shadow"},backgroundColor:"rgba(12,18,32,.94)",
|
||||
borderColor:"rgba(255,255,255,.12)",textStyle:{color:"#e8edf7",fontSize:12}},
|
||||
legend:{type:"scroll",top:4,textStyle:{color:"#8b9bb4",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||
tooltip:{trigger:"axis",axisPointer:{type:"shadow"},backgroundColor:"rgba(20,23,27,.96)",
|
||||
borderColor:"#262b33",textStyle:{color:"#e4e7eb",fontSize:12},
|
||||
formatter:ps=>{
|
||||
if(!ps || !ps.length) return "";
|
||||
const head = ps[0].axisValueLabel || ps[0].name || "";
|
||||
return `<b>${esc(head)}</b><br>` + ps.map(
|
||||
p=>`${p.marker}${esc(p.seriesName)}:${fmt(p.value)}`).join("<br>");
|
||||
}},
|
||||
legend:{type:"scroll",top:4,textStyle:{color:"#98a1ac",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||
xAxis:{type:"category",data:bks.map(b=>bucketLabel(b)),
|
||||
axisLabel:{color:"#8b9bb4",fontSize:11,rotate:bks.length>12?35:0},
|
||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}},axisTick:{show:false}},
|
||||
yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
||||
axisLabel:{color:"#98a1ac",fontSize:11,rotate:bks.length>12?35:0},
|
||||
axisLine:{lineStyle:{color:"#262b33"}},axisTick:{show:false}},
|
||||
yAxis:{type:"value",splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||
series:series
|
||||
},true);
|
||||
}
|
||||
@@ -845,19 +852,19 @@
|
||||
|
||||
charts.heat.setOption({
|
||||
grid:{left:single?68:78,right:20,top:16,bottom:46},
|
||||
tooltip:{backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
||||
textStyle:{color:"#e8edf7",fontSize:12},
|
||||
formatter:p=>`<b>${yData[p.value[1]]}</b> ${hours[p.value[0]]}:00<br>`
|
||||
tooltip:{backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||
formatter:p=>`<b>${esc(yData[p.value[1]])}</b> ${hours[p.value[0]]}:00<br>`
|
||||
+ `消耗 ${fmt(p.value[2])} 积分<br>调用 ${fmt0(p.value[3]||0)} 次`},
|
||||
xAxis:{type:"category",data:xData,splitArea:{show:true,areaStyle:{color:["rgba(255,255,255,.02)","transparent"]}},
|
||||
axisLabel:{color:"#8b9bb4",fontSize:10},axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}}},
|
||||
yAxis:{type:"category",data:yData,axisLabel:{color:"#8b9bb4",fontSize:10},
|
||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}}},
|
||||
xAxis:{type:"category",data:xData,splitArea:{show:true,areaStyle:{color:["#14171b","transparent"]}},
|
||||
axisLabel:{color:"#98a1ac",fontSize:10},axisLine:{lineStyle:{color:"#262b33"}}},
|
||||
yAxis:{type:"category",data:yData,axisLabel:{color:"#98a1ac",fontSize:10},
|
||||
axisLine:{lineStyle:{color:"#262b33"}}},
|
||||
visualMap:{min:0,max:max||1,calculable:false,orient:"horizontal",left:"center",bottom:6,
|
||||
itemWidth:11,itemHeight:90,textStyle:{color:"#8b9bb4",fontSize:10},
|
||||
inRange:{color:["#0f2233","#155e75","#0891b2","#22d3ee","#fbbf24","#f472b6"]}},
|
||||
itemWidth:11,itemHeight:90,textStyle:{color:"#98a1ac",fontSize:10},
|
||||
inRange:{color:["#0f2233","#22405e","#2f5b86","#4c8df6","#c9952f","#c07a9e"]}},
|
||||
series:[{type:"heatmap",data:data,
|
||||
itemStyle:{borderColor:"rgba(0,0,0,.25)",borderWidth:.5},
|
||||
itemStyle:{borderColor:"#0e1013",borderWidth:.5},
|
||||
emphasis:{itemStyle:{borderColor:"#fff",borderWidth:1}}}]
|
||||
},true);
|
||||
}
|
||||
@@ -886,31 +893,31 @@
|
||||
|
||||
charts.hist.setOption({
|
||||
grid:{left:52,right:52,top:38,bottom:52},
|
||||
tooltip:{trigger:"axis",backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
||||
textStyle:{color:"#e8edf7",fontSize:12},
|
||||
tooltip:{trigger:"axis",backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||
formatter:p=>{
|
||||
const i=p[0].dataIndex;
|
||||
return `<b>${HIST_LABELS[i]}</b><br>${fmt0(cnt[i])} 笔(占笔数 ${(cnt[i]/tk*100).toFixed(1)}%)<br>`
|
||||
+ `积分 ${fmt(cred[i])}(占 ${share[i]}%)<br>累计积分占比 ${cumShare[i]}%`;
|
||||
}},
|
||||
legend:{data:["笔数","积分占比","累计积分占比"],top:4,textStyle:{color:"#8b9bb4",fontSize:11},
|
||||
legend:{data:["笔数","积分占比","累计积分占比"],top:4,textStyle:{color:"#98a1ac",fontSize:11},
|
||||
icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||
xAxis:{type:"category",data:HIST_LABELS,axisLabel:{color:"#8b9bb4",fontSize:10,rotate:32},
|
||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}},axisTick:{show:false}},
|
||||
xAxis:{type:"category",data:HIST_LABELS,axisLabel:{color:"#98a1ac",fontSize:10,rotate:32},
|
||||
axisLine:{lineStyle:{color:"#262b33"}},axisTick:{show:false}},
|
||||
yAxis:[
|
||||
{type:"value",name:"笔数",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
||||
{type:"value",name:"%",max:100,nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
||||
splitLine:{show:false},axisLabel:{color:"#8b9bb4",fontSize:11}}
|
||||
{type:"value",name:"笔数",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||
{type:"value",name:"%",max:100,nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||
splitLine:{show:false},axisLabel:{color:"#98a1ac",fontSize:11}}
|
||||
],
|
||||
series:[
|
||||
{name:"笔数",type:"bar",barMaxWidth:28,data:cnt,
|
||||
itemStyle:{borderRadius:[4,4,0,0],
|
||||
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#a78bfa"},{offset:1,color:"rgba(167,139,250,.18)"}])}},
|
||||
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#c9952f"},{offset:1,color:"rgba(201,149,47,.18)"}])}},
|
||||
{name:"积分占比",type:"line",yAxisIndex:1,smooth:true,symbol:"circle",symbolSize:5,data:share,
|
||||
lineStyle:{color:"#fbbf24",width:2},itemStyle:{color:"#fbbf24"}},
|
||||
lineStyle:{color:"#c9952f",width:2},itemStyle:{color:"#c9952f"}},
|
||||
{name:"累计积分占比",type:"line",yAxisIndex:1,smooth:true,symbol:"none",data:cumShare,
|
||||
lineStyle:{color:"#22d3ee",width:1.6,type:"dashed"},itemStyle:{color:"#22d3ee"}}
|
||||
lineStyle:{color:"#4c8df6",width:1.6,type:"dashed"},itemStyle:{color:"#4c8df6"}}
|
||||
]
|
||||
},true);
|
||||
}
|
||||
@@ -929,25 +936,25 @@
|
||||
+ "右上角=又多又贵,右下角=多但便宜(免费模型贴在横轴上)";
|
||||
charts.scatter.setOption({
|
||||
grid:{left:62,right:34,top:34,bottom:46},
|
||||
tooltip:{backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
||||
textStyle:{color:"#e8edf7",fontSize:12},
|
||||
tooltip:{backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||
formatter:p=>{
|
||||
const d = p.data;
|
||||
return `<b>${d.name}</b><br>调用 ${fmt0(d.calls)} 次<br>积分 ${fmt(d.credits)}<br>`
|
||||
return `<b>${esc(d.name)}</b><br>调用 ${fmt0(d.calls)} 次<br>积分 ${fmt(d.credits)}<br>`
|
||||
+ `单次均价 ${fmt(d.avg)}<br>免费调用 ${fmt0(d.free)} 次(${d.calls?(d.free/d.calls*100).toFixed(1):"0"}%)`;
|
||||
}},
|
||||
xAxis:{type:"value",name:"调用次数",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
||||
yAxis:{type:"value",name:"积分消耗",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
||||
xAxis:{type:"value",name:"调用次数",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||
yAxis:{type:"value",name:"积分消耗",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||
series:[{
|
||||
type:"scatter", data:pts,
|
||||
symbolSize:v=>6 + Math.sqrt((v[2]||0)/maxAvg) * 32,
|
||||
itemStyle:{color:p=>{
|
||||
const r = p.data.calls/maxCalls;
|
||||
return `rgba(34,211,238,${(0.30 + 0.55*r).toFixed(2)})`;},
|
||||
borderColor:"rgba(167,139,250,.85)",borderWidth:1},
|
||||
label:{show:true,position:"top",color:"#8b9bb4",fontSize:10,formatter:p=>p.data.name},
|
||||
return `rgba(76,141,246,${(0.30 + 0.55*r).toFixed(2)})`;},
|
||||
borderColor:"rgba(201,149,47,.85)",borderWidth:1},
|
||||
label:{show:true,position:"top",color:"#98a1ac",fontSize:10,formatter:p=>p.data.name},
|
||||
emphasis:{itemStyle:{borderColor:"#fff"}}
|
||||
}]
|
||||
},true);
|
||||
@@ -961,7 +968,7 @@
|
||||
`<thead><tr><th>${DIM_NAME}</th><th class="num">调用次数</th><th class="num">计费 / 免费</th>
|
||||
<th class="num">积分消耗</th><th class="num">占比</th><th class="num">单次均值</th></tr></thead>
|
||||
<tbody>${g.map((o,i)=>`<tr>
|
||||
<td><span class="dot" style="background:${dimColor(i)}"></span>${DIM_LABEL(o.key)}</td>
|
||||
<td><span class="dot" style="background:${dimColor(i)}"></span>${esc(DIM_LABEL(o.key))}</td>
|
||||
<td class="num">${fmt0(o.calls)}</td>
|
||||
<td class="num">${fmt0(o.billableCalls)} / <span style="color:var(--blue)">${fmt0(o.freeCalls)}</span></td>
|
||||
<td class="num">${fmt(o.credits)}</td>
|
||||
@@ -990,7 +997,7 @@
|
||||
if(topState.mode==="global"){
|
||||
const t = (state.src && state.src.top) || [];
|
||||
items = t.slice(0,20).map(x=>({rank:x.rank, t:x.t, model:x.m, client:x.cl, credits:x.c, prompt:x.px}));
|
||||
$("#topHint").textContent = "全局 TOP 20(来自 top.json,由脚本按积分降序全量计算,不受上方筛选影响)";
|
||||
$("#topHint").textContent = "全部存档的 TOP 20(按积分降序,不受上方筛选影响)";
|
||||
}else{
|
||||
items = state.rows.slice().sort((a,b)=>b.credits-a.credits).slice(0,20)
|
||||
.map((x,i)=>({rank:i+1, t:x.t, model:x.model, client:x.client, credits:x.credits, prompt:x.prompt}));
|
||||
@@ -1002,8 +1009,8 @@
|
||||
<th class="num">积分</th><th>Prompt 摘要</th></tr></thead>
|
||||
<tbody>${items.map(x=>`<tr>
|
||||
<td class="num">${x.rank}</td>
|
||||
<td>${(x.t||"").slice(5,16)}</td>
|
||||
<td>${x.model}</td><td>${x.client}</td>
|
||||
<td>${esc((x.t||"").slice(5,16))}</td>
|
||||
<td>${esc(x.model)}</td><td>${esc(x.client)}</td>
|
||||
<td class="num">${fmt(x.credits)}
|
||||
<div class="bar-mini" style="width:46px;display:inline-block;vertical-align:2px;margin-left:6px">
|
||||
<div style="width:${(x.credits/max*100).toFixed(1)}%"></div></div></td>
|
||||
@@ -1016,7 +1023,17 @@
|
||||
function renderSources(){
|
||||
const mf = (state.src && state.src.manifest) || {};
|
||||
const srcs = mf.sources || [];
|
||||
const rows = srcs.map(s=>`<tr><td><code>${s.path}</code></td><td>${s.role||"-"}</td>
|
||||
// 服务端对非管理员不下发「数据源」清单(表名 / 库文件属于实现细节),
|
||||
// 这时把表格整块收起来 —— 只隐藏表格本身会留一个只有表头的空壳。
|
||||
const wrap = $("#tblSrc").parentNode;
|
||||
if(wrap){
|
||||
wrap.style.display = srcs.length ? "" : "none";
|
||||
const cap = wrap.previousElementSibling;
|
||||
if(cap) cap.textContent = srcs.length
|
||||
? "数据由本项目 Flask 后端从 SQLite 实时聚合"
|
||||
: "你的数据由服务端实时聚合,口径自检如下";
|
||||
}
|
||||
const rows = srcs.map(s=>`<tr><td><code>${esc(s.path)}</code></td><td>${esc(s.role||"-")}</td>
|
||||
<td class="num">${fmt0(s.count)}</td><td class="num">${s.bytes? (s.bytes/1024).toFixed(1)+" KB" : "查询"}</td></tr>`).join("");
|
||||
$("#tblSrc").innerHTML =
|
||||
`<thead><tr><th>数据源</th><th>角色</th><th class="num">条目</th><th class="num">体积</th></tr></thead>
|
||||
@@ -1031,11 +1048,10 @@
|
||||
$("#srcCheck").innerHTML =
|
||||
`<div>存档总量(SQLite 正本):${fmt0(t.records||0)} 条 / ${fmt(t.credits||0)} 积分 · `
|
||||
+ `${(t.days||[]).length} 个活跃日 · ${(t.months||[]).length} 个月</div>`
|
||||
+ (w ? `<div>当前窗口(SQL 聚合):${fmt0(w.records||0)} 条 / ${fmt(w.credits||0)} 积分 | `
|
||||
+ `页面按明细实时聚合:${fmt0(state.all.length)} 条 / ${fmt(mine)} 积分 `
|
||||
+ `<span class="${ok?"ok":"bad"}">${ok?"✔ 一致":"✖ 不一致"}</span></div>` : "")
|
||||
+ `<div>口径:明细 / 逐日 / 维度 / TOP 全部由 SQLite 实时计算(无中间 JSON 层) · `
|
||||
+ `Cookie ${h.cookie?"已配置":"<b style='color:#f87171'>未配置</b>"}`
|
||||
+ (w ? `<div>当前窗口:SQL 聚合 ${fmt0(w.records||0)} 条 / ${fmt(w.credits||0)} 积分,`
|
||||
+ `页面重算 ${fmt0(state.all.length)} 条 / ${fmt(mine)} 积分 `
|
||||
+ `<span class="${ok?"ok":"bad"}">${ok?"一致":"不一致"}</span></div>` : "")
|
||||
+ `<div>Cookie ${h.cookie?"已配置":"<b style='color:#cf6679'>未配置</b>"}`
|
||||
+ `${h.lastRunAt?` · 最近采集 ${h.lastRunAt}(${h.lastRunStatus||"-"})`:""}</div>`;
|
||||
}
|
||||
|
||||
@@ -1135,8 +1151,8 @@
|
||||
`<b>${d}</b>${(pd && pd.date===d)?`<span class="tag">残日 · 截至 ${pd.hhmm}</span>`:""}<br>`
|
||||
+ `积分消耗 ${fmt(o.credits)}<br>`
|
||||
+ `调用次数 ${fmt0(o.calls)} 次(计费 ${fmt0(o.billableCalls||0)} · 免费 ${fmt0(o.freeCalls||0)})<br>`
|
||||
+ `TOP 模型 ${tm? tm[0]+"("+fmt(tm[1])+")" : "-"}<br>`
|
||||
+ `TOP 时段 ${th? th[0]+":00("+fmt(th[1])+")" : "-"}`;
|
||||
+ `TOP 模型 ${tm? esc(tm[0])+"("+fmt(tm[1])+")" : "-"}<br>`
|
||||
+ `TOP 时段 ${th? esc(th[0])+":00("+fmt(th[1])+")" : "-"}`;
|
||||
this.el.style.display = "block";
|
||||
}
|
||||
const w = this.el.offsetWidth, h = this.el.offsetHeight;
|
||||
@@ -1224,13 +1240,11 @@
|
||||
state.all.forEach(x=>{ b[x.client] = (b[x.client]||0) + x.credits; });
|
||||
const tot = Object.values(b).reduce((s,v)=>s+v,0)||1;
|
||||
const parts = Object.entries(b).sort((a,c)=>c[1]-a[1])
|
||||
.map(([k,v])=>k+" "+(v/tot*100).toFixed(1)+"%").join(" · ");
|
||||
.map(([k,v])=>esc(k)+" "+(v/tot*100).toFixed(1)+"%").join(" · ");
|
||||
const t = (state.mf && state.mf.totals) || {};
|
||||
$(".sub").innerHTML = "数据来源:本项目 Flask 后端采集云端用量明细 → SQLite("
|
||||
+ "<code>data/usage.sqlite</code>),本页经 <code>/api</code> 实时聚合,"
|
||||
+ "无中间 JSON 层;本窗口客户端构成:" + parts
|
||||
+ `(窗口内 ${fmt0(state.all.length)} 条 / ${fmt(state.all.reduce((s,x)=>s+x.credits,0))} 积分,`
|
||||
+ `全量 ${fmt0(t.records||0)} 条)`;
|
||||
$(".sub").innerHTML = "数据来源:<code>data/usage.sqlite</code>,经 <code>/api</code> 实时聚合;"
|
||||
+ `本窗口 ${fmt0(state.all.length)} 条 / ${fmt(state.all.reduce((s,x)=>s+x.credits,0))} 积分,`
|
||||
+ `全量 ${fmt0(t.records||0)} 条 · 客户端构成:` + parts;
|
||||
}
|
||||
|
||||
// 取数 + 渲染:窗口变了先按新窗口拉一次明细,再交给原有的渲染管线
|
||||
|
||||
@@ -39,9 +39,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flash warn" style="margin-bottom:16px">
|
||||
<b>恢复会覆盖当前全部数据</b>(账号、用量、配置一起换成归档里的那一份)。
|
||||
系统会在恢复前<b>自动先备份一次当前库</b>并保留在列表里,恢复错了可以再恢复到那一份。
|
||||
恢复完成后所有既有登录会话失效,需要重新登录。
|
||||
恢复会用归档内容覆盖当前全部数据(账号、用量、配置)。系统会在恢复前自动备份当前库并保留在列表里,
|
||||
恢复错了可以再恢复到那一份。恢复完成后所有登录会话失效,需要重新登录。
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
@@ -69,12 +68,8 @@
|
||||
value="{{ s.backup_keep }}">
|
||||
<em class="unit">份(超出的自动删最旧的)</em></label>
|
||||
<button class="btn primary" type="submit">保存备份设置</button>
|
||||
<p class="hint">
|
||||
自动备份在调度线程里执行,有采集任务在跑时会自动让路、下一轮再打。
|
||||
备份使用 SQLite 的在线备份 API,<b>采集写入期间也能拿到一致快照</b> ——
|
||||
这一点是手工 <code>cp usage.sqlite</code> 做不到的。
|
||||
每份归档打完后按「保留份数」清理最旧的那些。
|
||||
</p>
|
||||
<p class="hint">自动备份在调度线程里执行,有采集在跑时让路到下一轮。走 SQLite 在线备份 API,
|
||||
采集写入期间也能拿到一致快照。打完按「保留份数」清理最旧的。</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -91,18 +86,14 @@
|
||||
<code>manifest.json</code>(时间、行数、积分、逐文件校验值)
|
||||
</td></tr>
|
||||
<tr><th>为什么带上密钥</th><td>
|
||||
没有 <code>instance.json</code> 里的 <code>cookie_key</code>,归档里的凭证密文
|
||||
就永远解不开 —— 那样的「恢复」等于把所有账号的 Cookie 弄丢。
|
||||
没有 <code>instance.json</code> 里的 <code>cookie_key</code>,归档中的凭证密文就解不开。
|
||||
</td></tr>
|
||||
<tr><th>因此</th><td>
|
||||
<b>归档等于全库数据 + 密钥</b>,下载后请当作机密文件保管;
|
||||
它<b>不会</b>进入代码仓库、也不会被打进镜像(见 <code>.gitignore</code> /
|
||||
<code>.dockerignore</code>)。
|
||||
<tr><th>保管要求</th><td>
|
||||
归档 = 全库数据 + 密钥,下载后请按机密文件保管;它不会进入仓库,也不会被打进镜像。
|
||||
</td></tr>
|
||||
<tr><th>目录独立</th><td>
|
||||
备份目录刻意<b>不在 <code>data/</code> 里面</b>:容器里 <code>data/</code> 是数据卷,
|
||||
<code>docker compose down -v</code> 会把正本和副本一起删掉。
|
||||
容器里它挂在独立卷 <code>wb_backups</code> 上。
|
||||
备份目录不在 <code>data/</code> 内,避免容器 <code>down -v</code> 时正本与副本一起被删;
|
||||
容器里挂在独立卷 <code>wb_backups</code> 上。
|
||||
</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
@@ -158,11 +149,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">
|
||||
列表里的「记录 / 积分 / 账号」是归档生成时的计数,用来挑一份合适的恢复 ——
|
||||
恢复前程序还会再校验一次归档的完整性与库结构版本,校验不过不会动你的数据。
|
||||
档案文件名只能由本程序生成(服务端会拒绝任何带路径的文件名)。
|
||||
</p>
|
||||
<p class="hint">「记录 / 积分 / 账号」是归档生成时的计数。恢复前会先校验归档完整性与库结构版本,校验不过不动数据。</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -68,8 +68,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 调度与采集参数由管理员统一设定<br>
|
||||
每个账号只使用并只见得到<b>自己的</b> Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
|
||||
{{ project_title }} · {{ project_name }} · 数据正本 <code>data/usage.sqlite</code>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -27,15 +27,11 @@
|
||||
</div>
|
||||
<p class="hint">
|
||||
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}
|
||||
{% if s.cookie_at %}({{ s.cookie_at }} 更新){% endif %}
|
||||
—— 页面与接口都不回传明文,数据库里也是密文。{% endif %}
|
||||
{% if s.cookie_at %}({{ s.cookie_at }} 更新){% endif %}。{% endif %}
|
||||
<br>获取方式:Chrome 打开 <code>https://www.workbuddy.cn/profile/plans-usage</code> → F12 → Network →
|
||||
任选一个 <code>billing</code> 请求 → 复制 Request Headers 里的 <code>cookie</code> 与 <code>user-agent</code>
|
||||
(<b>两者必须取自同一次请求</b>),粘贴到下面。
|
||||
<br><b>请粘贴你自己账号的 Cookie</b>:采集只使用本人凭证,各账号的数据互不可见。
|
||||
{% if not is_admin %}
|
||||
<br><b>这一块是你唯一可以修改的配置</b> —— 其余参数与调度时刻由管理员统一设定。
|
||||
{% endif %}
|
||||
任选一个 <code>billing</code> 请求 → 复制 Request Headers 的 <code>cookie</code> 与 <code>user-agent</code>
|
||||
(两者须取自同一次请求)。
|
||||
<br>采集只使用本人凭证,各账号数据互不可见。{% if not is_admin %}普通账号只有这一块可改。{% endif %}
|
||||
</p>
|
||||
<form id="formCred">
|
||||
<label class="col">Cookie
|
||||
@@ -92,11 +88,10 @@
|
||||
</label>
|
||||
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
|
||||
<button class="btn primary" type="submit">保存采集参数</button>
|
||||
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。
|
||||
这些是<b>实例级</b>参数,改一次对本机所有账号生效。</p>
|
||||
<p class="hint">整日校验按天比对云端 total 与本地记录并补入缺失项,0 表示关闭。实例级参数,对本机所有账号生效。</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="hint">这些参数影响采集行为与云端压力,属于整机策略,普通账号只读。</p>
|
||||
<p class="hint">这些属于整机策略,普通账号只读。</p>
|
||||
<table class="kv">
|
||||
<tr><th>接口基址</th><td class="mono">{{ s.api_base }}</td></tr>
|
||||
<tr><th>接口路径</th><td class="mono">{{ s.api_path }}</td></tr>
|
||||
@@ -118,8 +113,7 @@
|
||||
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
||||
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
||||
<button class="btn primary" type="submit">修改密码</button>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
|
||||
修改成功后当前会话仍有效,不必重新登录。</p>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,需包含大写字母、小写字母、数字、符号中的至少两类。修改后当前会话仍有效。</p>
|
||||
</form>
|
||||
|
||||
<hr class="sect-divider">
|
||||
@@ -143,22 +137,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if not is_admin %}
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>为什么采集参数是只读的</h2>
|
||||
<span class="tag mute">普通账号</span>
|
||||
</div>
|
||||
<p class="hint" style="margin:0">
|
||||
你只能维护<b>本人账号的凭证</b>(Cookie 与 User-Agent)—— 采集始终只用你自己的这份凭证,
|
||||
拿回来的数据也只会存在你自己的作用域里,别人看不到、你也看不到别人的。
|
||||
分页大小、超时、接口地址、TLS 校验、调度时刻这些属于<b>整机策略</b>:它们既关系到云端
|
||||
压力,也关系到所有人的采集是否安全,因此统一由管理员设定。
|
||||
<br>如果你确实需要调整,把上面任意一项截图给管理员即可。
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
@@ -191,11 +169,7 @@
|
||||
value="{{ s.captcha_length }}">
|
||||
<em class="unit">4~6 位</em></label>
|
||||
<button class="btn primary" type="submit">保存实例设置</button>
|
||||
<p class="hint">
|
||||
验证码的答案只存在服务端 <code>captchas</code> 表里(下发到浏览器的只是一个随机 id),
|
||||
一次性使用、5 分钟过期 —— 所以答案不会随会话 Cookie 泄漏出去。
|
||||
关闭验证码会显著放大被撞库与批量注册的风险,只有在前面已经有可信网关时才考虑。
|
||||
</p>
|
||||
<p class="hint">验证码答案只存在服务端 <code>captchas</code> 表,一次性使用、5 分钟过期。关闭会显著放大撞库与批量注册的风险。</p>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
<p class="foot-note">还没有账号?<a href="{{ url_for('views.register') }}">自助注册</a></p>
|
||||
{% endif %}
|
||||
<p class="foot-note">
|
||||
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即修改密码。<br>
|
||||
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -58,10 +58,9 @@
|
||||
<tr><th>Cookie</th><td>{% if mf.health.cookie %}<span class="tag ok">已配置</span>{% else %}<span class="tag bad">未配置</span> <a href="{{ url_for('views.config_page') }}">去配置</a>{% endif %}</td></tr>
|
||||
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
|
||||
</table>
|
||||
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。
|
||||
{% if current_user().is_admin %}所有时刻与开关都在
|
||||
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。{% else %}时刻由管理员统一设定,
|
||||
你可以在<a href="{{ url_for('views.tasks') }}">任务管理</a>里查看,也可以随时手动采集本人数据。{% endif %}</p>
|
||||
<p class="hint">调度随 Web 进程运行。{% if current_user().is_admin %}时刻与开关在
|
||||
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。{% else %}时刻由管理员设定,可在
|
||||
<a href="{{ url_for('views.tasks') }}">任务管理</a>查看或手动采集。{% endif %}</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -59,10 +59,8 @@
|
||||
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
||||
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
||||
<button class="btn primary" type="submit">修改密码</button>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类,
|
||||
且不能是常见弱口令。
|
||||
<b>修改成功后其他设备上的登录会立刻失效</b>(本机这次会话保留,不必重新登录)——
|
||||
「怀疑被盗所以改密码」才能真正生效。</p>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,需包含大写字母、小写字母、数字、符号中的至少两类。
|
||||
修改成功后其他设备上的登录会立刻失效,本机会话保留。</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@@ -73,16 +71,14 @@
|
||||
<span class="tag mute">数据可携带</span>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:0">
|
||||
打包下载<b>属于你账号</b>的所有内容:用量明细、采集历史、与你相关的操作审计、
|
||||
以及账号信息与对你有效的配置。
|
||||
<br><b>不含</b> Cookie 明文(只写「配没配、多少字符、什么时候更新的」)、
|
||||
也不含其他任何账号的数据与实例级运行日志。
|
||||
下载本账号的用量明细、采集历史、操作审计与有效配置。
|
||||
不含 Cookie 明文,也不含其他账号的数据与实例级运行日志。
|
||||
</p>
|
||||
<div class="btnrow">
|
||||
<a class="btn primary" href="{{ url_for('views.profile_export') }}">导出我的全部数据(zip)</a>
|
||||
<a class="btn ghost" href="{{ url_for('views.records') }}">只导出用量明细(CSV)</a>
|
||||
</div>
|
||||
<p class="hint">导出动作按账号有最小间隔(10 秒)保护,避免脚本反复触发整表扫描。</p>
|
||||
<p class="hint">两次导出之间至少间隔 10 秒。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -105,10 +101,8 @@
|
||||
</td></tr>
|
||||
</table>
|
||||
<p class="hint">
|
||||
凭证以密文形式存在数据库里(主密钥在 <code>data/instance.json</code>),
|
||||
页面与接口**任何时候都不回传明文**,只显示长度与尾部 4 位。要更换请到
|
||||
<a href="{{ url_for('views.config_page') }}">配置管理</a>粘贴新的 Cookie 与 User-Agent
|
||||
(两者必须取自同一次浏览器请求)。
|
||||
凭证以密文入库(主密钥在 <code>data/instance.json</code>),页面与接口都不回传明文。
|
||||
更换请到<a href="{{ url_for('views.config_page') }}">配置管理</a>重新粘贴 Cookie 与 User-Agent。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -68,9 +68,7 @@
|
||||
<button class="btn primary" type="submit">查询</button>
|
||||
{% if has_filter %}<a class="btn ghost" href="{{ url_for('views.records') }}">重置</a>{% endif %}
|
||||
</form>
|
||||
<p class="hint">
|
||||
日期留空表示不限。有关键词时会同时匹配 User Prompt 与 RequestID(按天全表扫描,区间越大越慢)。
|
||||
</p>
|
||||
<p class="hint">日期留空表示不限。关键词会同时匹配 User Prompt 与 RequestID,区间越大越慢。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>任务管理</h1>
|
||||
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里能看到本人账号的运行历史,也能手动触发与按区间回填</p>
|
||||
<p class="lead">调度随 Web 进程运行;这里可查看本人运行历史、手动采集与按区间回填</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
|
||||
@@ -30,11 +30,8 @@
|
||||
<label class="row"><span>每日时刻</span>
|
||||
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
|
||||
</label>
|
||||
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
|
||||
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。
|
||||
<br><b>最多 {{ max_slots }} 个时刻</b>(当前 {{ sch.times|length }} 个)—— 时刻数量直接决定
|
||||
采集频次,是对外提供服务时控制云端压力的旋钮。需要更多请先到「配置管理」
|
||||
把「每日调度时刻上限」调大。</p>
|
||||
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>),保存后立即生效。
|
||||
最多 {{ max_slots }} 个时刻(当前 {{ sch.times|length }} 个),需要更多请先在「配置管理」调大上限。</p>
|
||||
<label class="row"><span>启动补跑</span>
|
||||
<select name="catch_up">
|
||||
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
|
||||
@@ -43,10 +40,10 @@
|
||||
</label>
|
||||
<label class="row"><span>补跑宽限</span>
|
||||
<input name="catch_up_grace_hours" value="{{ s_grace }}" type="number" min="1" max="168">
|
||||
<em class="unit">小时(超过就不补,避免开机狂刷)</em>
|
||||
<em class="unit">小时(超过则不补)</em>
|
||||
</label>
|
||||
<button class="btn primary" type="submit">保存调度配置</button>
|
||||
<p class="hint">这套时刻是<b>实例级</b>的:本机上所有账号在同一个时刻各自采集自己的数据。</p>
|
||||
<p class="hint">时刻是实例级的:本机所有账号在此时刻各自采集自己的数据。</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<table class="kv">
|
||||
@@ -55,11 +52,7 @@
|
||||
<tr><th>启动补跑</th><td>{% if sch.catch_up %}<span class="tag">开启</span>(宽限 {{ s_grace }} 小时){% else %}关闭{% endif %}</td></tr>
|
||||
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
|
||||
</table>
|
||||
<p class="hint">
|
||||
调度时刻与采集参数由<b>管理员统一设定</b>,普通账号只读。
|
||||
你随时可以在右上角点「立即采集一次」拉取本人账号的最新用量,
|
||||
也可以在下面按区间补采自己的历史数据 —— 采集始终只用<b>你自己的</b> Cookie。
|
||||
</p>
|
||||
<p class="hint">调度时刻与采集参数由管理员设定,普通账号只读。可在右上角手动采集,或按区间补采本人历史数据。</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -83,11 +76,8 @@
|
||||
data-min-days="{{ max_days }}"></label>
|
||||
<label class="row"><span>结束日期</span><input type="date" name="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
|
||||
<button class="btn" type="submit">按区间补采</button>
|
||||
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
|
||||
<br><b>单次最长 {{ max_days }} 天</b>(超过会被服务端拒绝,请分批补),
|
||||
且<b>同一账号两次采集之间需间隔 {{ min_gap }} 秒</b>、<b>有任务在跑时不能再发起</b> ——
|
||||
这三条是为了避免把云端接口与本站线程池打满。
|
||||
手动点「立即采集一次」只走增量(从最后一条记录续拉),通常几秒完成。</p>
|
||||
<p class="hint">按 <code>RequestID</code> 去重,已存在的记录不会重复计入。单次最长 {{ max_days }} 天,
|
||||
两次采集间隔需 {{ min_gap }} 秒,有任务在跑时不能发起。「立即采集一次」走增量,通常几秒完成。</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p class="lead">维护账号、状态与管理员身份。单价数据<b>按账号隔离</b>——管理员也看不到别人的用量与凭证</p>
|
||||
<p class="lead">维护账号、状态与管理员身份。用量数据按账号隔离,管理员也看不到别人的用量与凭证</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="tag accent">仅管理员可见</span>
|
||||
@@ -36,8 +36,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn primary" type="submit">创建账号</button>
|
||||
<p class="hint">密码至少 8 位且需含两类以上字符。新账号默认是<b>普通账号</b>;
|
||||
管理员身份请显式选择。无论哪种身份,都需要各自配置自己的 Cookie 才能采集。</p>
|
||||
<p class="hint">密码至少 8 位且需含两类以上字符。无论何种身份,都需要各自配置 Cookie 才能采集。</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -92,10 +91,8 @@
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">
|
||||
「停用」会让该账号<b>立刻</b>失效(每个请求都会校验状态,不必等会话过期),
|
||||
其数据与 Cookie 都保留;「删除」是<b>不可逆</b>的,会连同该账号的用量数据与 Cookie
|
||||
一起删除(接口层另有保留数据的开关,供脚本调用时指定)。
|
||||
管理员不能取消自己的管理员身份、不能停用或删除自己,也不能删掉最后一个启用的管理员。
|
||||
「停用」立即失效,数据与 Cookie 保留;「删除」不可逆,会连同用量数据与 Cookie 一起删除。
|
||||
管理员不能取消自己的管理员身份,也不能停用或删除自己。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -586,10 +586,13 @@ def records_export():
|
||||
finally:
|
||||
own.close()
|
||||
|
||||
# 文件名带账号名:多人导出到同一目录时不会互相覆盖
|
||||
name = "usage_%s_%s_%s.csv" % (u["username"], frm or "all", to or db.now_str()[:10])
|
||||
resp = Response(gen(), mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="%s"' % name})
|
||||
# 文件名带账号名:多人导出到同一目录时不会互相覆盖。
|
||||
# 名字必须过 safe_filename —— 响应头里拼一个含引号或 CR/LF 的用户名
|
||||
# 就是响应头注入(username 并不总是注册正则的产物,见 security.safe_filename)。
|
||||
name = "usage_%s_%s_%s.csv" % (security.safe_filename(u["username"], fallback="u%d" % uid),
|
||||
frm or "all", to or db.now_str()[:10])
|
||||
resp = Response(gen(), mimetype="text/csv; charset=utf-8")
|
||||
resp.headers["Content-Disposition"] = security.content_disposition(name)
|
||||
# 导出可能很慢,避免 nginx 之类的前置代理先缓冲整个响应体
|
||||
resp.headers["X-Accel-Buffering"] = "no"
|
||||
return resp
|
||||
@@ -650,8 +653,8 @@ def _zip_stream(buf, filename, mimetype="application/zip"):
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
resp = Response(gen(), mimetype=mimetype,
|
||||
headers={"Content-Disposition": 'attachment; filename="%s"' % filename})
|
||||
resp = Response(gen(), mimetype=mimetype)
|
||||
resp.headers["Content-Disposition"] = security.content_disposition(filename)
|
||||
resp.headers["X-Accel-Buffering"] = "no"
|
||||
return resp
|
||||
|
||||
@@ -768,7 +771,11 @@ def profile_export():
|
||||
"导出时间:%s\n程序版本:%s\n"
|
||||
% (u["username"], db.now_str(), _app_version())).encode("utf-8"))
|
||||
|
||||
name = "my-data_%s_%s.zip" % (u["username"], db.now_str()[:10])
|
||||
# 用户名先收敛再拼名字:老库里可能存在不符合注册正则的账号名,
|
||||
# 里面若带反斜杠,content_disposition 为了防路径会把它之后的整段截掉,
|
||||
# 结果文件名退化成 "x_2026-09-18.zip"。先收敛就不会走到那条截断路径。
|
||||
name = "my-data_%s_%s.zip" % (security.safe_filename(u["username"], fallback="u%d" % uid),
|
||||
db.now_str()[:10])
|
||||
db.audit(conn, "export_self", u["username"], "导出个人全部数据(%s)" % name,
|
||||
_ip(), uid)
|
||||
return _zip_stream(buf, name)
|
||||
|
||||
在新工单中引用
屏蔽一个用户