chore: 项目定名为 workbuddy-portal,容器化并补齐文档体系

## 项目定名
- 目录 wb_usage_portal → workbuddy-portal
- Python 包 wb_usage → workbuddy_portal(含 session cookie 名)
- 界面品牌统一为 WorkBuddy Portal;项目标识收敛到 config 单一来源

## 容器化
- Dockerfile:多阶段构建,依赖层与源码解耦;非 root(uid 1000);内置健康检查
- docker-compose.yml:单服务 + 绑定挂载 data/logs + 日志轮转 + TZ
- docker/entrypoint.sh:幂等初始化 → exec serve(LF 行尾,已由 .gitattributes 锁定)
- docker/healthcheck.py:纯标准库探活 /login(slim 镜像无 curl)
- .dockerignore / .env.example;数据目录可用 WB_DATA_DIR 等环境变量覆盖

## 文档
- docs/USER-GUIDE.md    用户使用手册(含 9 张真实界面截图)
- docs/DEPLOYMENT.md    部署运维(Docker / 裸机 / 反代 / 备份 / 推 Gitea 注册表)
- docs/ARCHITECTURE.md  架构与设计说明(含已知坑与红线、验证体系)
- docs/API.md           接口参考(路径 / 参数 / 返回结构 / 错误码)
- docs/FAQ.md           常见问题;docs/CHANGELOG.md 变更日志

## 修复缺陷(8)
1. /records/export 必然 500:生成器在请求上下文销毁后才迭代,改用自建连接
2. 大屏页图表全白:相对路径把 echarts.min.js 解析成 /vendor/... → 404
3. /users 500:路由已注册但模板缺失
4. 明细页日期筛选失效:视图传 f.frm、模板读 f.from
5. 配置页维护按钮全死:调用了不存在的 WBU.bindMaint()
6. 审计只能看最近 40 条:LIMIT 写死
7. 明细页多跑一条无用 SELECT:day_list() 取了没人用
8. 登录页锁定阈值未从配置注入

## 安全加固
- 新增 safe_next():拒绝 //evil.com 等协议相对 URL 的开放重定向
- 缺 CSRF 的写请求统一 400
- 默认开启云端 HTTPS 证书校验(ssl_verify=1);Cookie 是账号凭证
- 登录失败计数表加上限与 TTL
- /logout 拆分为 POST(执行) + GET(仅提示),防 <img src=/logout> 静默退出
- settings 内部簿记键 slot:* 读写两侧过滤,不再从 /api/settings 泄漏

## 内部质量与工具
- 设置项写时校验 + 读时兜底,杜绝「一个手滑的数字让采集整个跑不起来」
- 全局 ValueError → 400:手写 query string 不再暴露 500 页面
- CSV 导出改 csv.writer 流式写入(原手工拼串,字段含逗号会串列)
- bundle 明细加 20000 上限并回传 recordsTotal/recordsTruncated,不静默丢数据
- tools/smoke.py 离线回归 99 项;tools/check_live.py 真实 HTTP 56 项
- tools/shots.py Playwright 逐页截图 + JS 报错收集

## 验证
- compileall 通过;smoke 99/99;对容器实例 check_live 56/56;截图 0 JS 报错
- 容器内采集实测成功(trigger=startup 补跑:新增 11 条)
这个提交包含在:
2026-09-14 14:55:50 +08:00
当前提交 86631ae7ab
共修改 58 个文件,包含 9409 行新增和 0 行删除
+64
查看文件
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>{% block title %}{{ project_title }}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
</head>
<body>
{% set nav = active|default('') %}
{% set on_dash = request.path.startswith('/dashboard') %}
{% if current_user() %}
<header class="topbar">
<div class="brand">
<span class="dot"></span>
<a href="{{ url_for('views.overview') }}"><b>{{ project_title }}</b></a>
<span class="ver">v{{ app_version }}</span>
</div>
<nav>
<a href="{{ url_for('views.overview') }}" class="{{ 'on' if nav=='overview' }}">概览</a>
<a href="{{ url_for('views.dashboard') }}" class="{{ 'on' if on_dash }}">用量大屏</a>
<a href="{{ url_for('views.records') }}" class="{{ 'on' if nav=='records' }}">数据明细</a>
<a href="{{ url_for('views.tasks') }}" class="{{ 'on' if nav=='tasks' }}">任务管理</a>
<a href="{{ url_for('views.config_page') }}" class="{{ 'on' if nav=='config' }}">配置管理</a>
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
{% if current_user().is_admin %}
<a href="{{ url_for('views.users_page') }}" class="{{ 'on' if nav=='users' }}">用户管理</a>
{% endif %}
</nav>
<div class="me">
<span class="who"><b>{{ current_user().display_name }}</b>{% if current_user().is_admin %} <span class="tag accent">管理员</span>{% endif %}</span>
{# 退出用 POST + CSRF:GET 型退出会被 <img src="/logout"> 这类请求静默触发 #}
<form method="post" action="{{ url_for('views.logout_post') }}" style="margin:0">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button class="btn ghost sm" type="submit">退出</button>
</form>
</div>
</header>
{% endif %}
<main class="wrap">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% if msgs %}
<div class="flashes">
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block body %}{% endblock %}
</main>
<footer class="foot">
<b>{{ project_title }}</b> · {{ project_name }} · 采集 / 存储 / 呈现三合一 · 数据正本 <code>data/usage.sqlite</code><br>
采集在 Web 进程内按配置时刻执行,无需外部计划任务
</footer>
<script>
window.WB_CSRF = "{{ csrf_token() }}";
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+109
查看文件
@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}配置管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>配置管理</h1>
<p class="lead">凭证、采集参数、维护动作都在这里;所有配置存在数据库,改完立即生效</p>
</div>
</div>
<section class="card">
<div class="cardhead">
<h2>云端凭证</h2>
<span class="tag {{ 'ok' if s.cookie_hint else 'bad' }}">{{ '已配置' if s.cookie_hint else '未配置' }}</span>
</div>
<p class="hint">
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}(页面与接口都不回传明文){% 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>),粘贴到下面。
</p>
<form id="formCred">
<label class="col">Cookie
<textarea name="cookie" rows="4" placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie" spellcheck="false"></textarea>
</label>
<label class="col">User-Agent
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
</label>
<button class="btn primary" type="submit">保存凭证</button>
</form>
</section>
<div class="grid2">
<section class="card">
<h2>采集参数</h2>
<form id="formCollect">
<label class="row"><span>接口基址</span><input name="api_base" value="{{ s.api_base }}" spellcheck="false"></label>
<label class="row"><span>接口路径</span><input name="api_path" value="{{ s.api_path }}" spellcheck="false"></label>
{% set b = num_settings %}
<label class="row"><span>分页大小</span>
<input name="page_size" type="number" min="{{ b.page_size[0] }}" max="{{ b.page_size[1] }}" value="{{ s.page_size }}">
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
<label class="row"><span>断点回退</span>
<input name="rewind_minutes" type="number" min="{{ b.rewind_minutes[0] }}" max="{{ b.rewind_minutes[1] }}" value="{{ s.rewind_minutes }}">
<em class="unit">分钟</em></label>
<label class="row"><span>时间漂移容差</span>
<input name="drift_tolerance_minutes" type="number" min="{{ b.drift_tolerance_minutes[0] }}" max="{{ b.drift_tolerance_minutes[1] }}" value="{{ s.drift_tolerance_minutes }}">
<em class="unit">分钟</em></label>
<label class="row"><span>Prompt 截断</span>
<input name="max_prompt" type="number" min="{{ b.max_prompt[0] }}" max="{{ b.max_prompt[1] }}" value="{{ s.max_prompt }}">
<em class="unit">字符(0 = 不截断)</em></label>
<label class="row"><span>整日校验天数</span>
<input name="verify_days" type="number" min="{{ b.verify_days[0] }}" max="{{ b.verify_days[1] }}" value="{{ s.verify_days }}">
<em class="unit">天</em></label>
<label class="row"><span>请求超时</span>
<input name="timeout" type="number" min="{{ b.timeout[0] }}" max="{{ b.timeout[1] }}" value="{{ s.timeout }}">
<em class="unit">秒</em></label>
<label class="row"><span>校验 TLS 证书</span>
<select name="ssl_verify">
<option value="1" {{ 'selected' if s.ssl_verify != '0' }}>校验(推荐)</option>
<option value="0" {{ 'selected' if s.ssl_verify == '0' }}>不校验(仅自签/企业代理时用)</option>
</select>
</label>
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
<button class="btn primary" type="submit">保存采集参数</button>
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。</p>
</form>
</section>
<section class="card">
<h2>修改登录密码</h2>
<form id="formPwd">
<label class="col">原密码<input name="old" type="password" autocomplete="current-password"></label>
<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">至少 6 位。修改成功后当前会话仍有效,不必重新登录。</p>
</form>
<hr class="sect-divider">
<h3>维护动作</h3>
<div class="btnrow">
<button class="btn" type="button" data-maint="fill-prompt"
title="把云端仍保留、但本地为空的 User Prompt 补回来">补全缺失 Prompt</button>
<button class="btn" type="button" data-maint="export-csv"
title="导出与官网 xlsx 同构的全量 CSV 到 data/exports/">导出全量 CSV</button>
<button class="btn" type="button" data-maint="vacuum"
title="checkpoint + VACUUM,回收删除后的空闲页">整理数据库</button>
</div>
<p class="hint">补全 Prompt 需要联网并逐天重拉云端;导出与整理只动本地数据。</p>
<div class="btnrow" style="margin-top:14px">
<a class="btn ghost" href="{{ url_for('views.records_export') }}">按当前明细页筛选导出</a>
{% if current_user().is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formCred', '/api/settings');
WBU.bindForm('#formCollect', '/api/settings');
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
WBU.bindMaint('[data-maint]');
</script>
{% endblock %}
+16
查看文件
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}{{ code }} · {{ project_title }}{% endblock %}
{% block body %}
<div class="card errpage">
<div class="code">{{ code }}</div>
<p>{{ message }}</p>
<div class="btnrow" style="justify-content:center">
{% if current_user() %}
<a class="btn primary" href="{{ url_for('views.overview') }}">回到概览</a>
<a class="btn ghost" href="javascript:history.back()">返回上一页</a>
{% else %}
<a class="btn primary" href="{{ url_for('views.login') }}">去登录</a>
{% endif %}
</div>
</div>
{% endblock %}
+25
查看文件
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}登录 · {{ project_title }}{% endblock %}
{% block body %}
<div class="loginwrap">
<form class="card login" method="post" action="{{ url_for('views.login') }}">
<div class="logo">W</div>
<h1>{{ project_title }}</h1>
<p class="hint">{{ project_desc }}</p>
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ next_url or '' }}">
<label>用户名
<input name="username" value="{{ username or '' }}" autocomplete="username"
autofocus required maxlength="32">
</label>
<label>密码
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="btn primary" type="submit">登 录</button>
<p class="foot-note">
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即到「配置管理」修改密码。<br>
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
</p>
</form>
</div>
{% endblock %}
+155
查看文件
@@ -0,0 +1,155 @@
{% extends "base.html" %}
{% block title %}日志管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>日志管理</h1>
<p class="lead">采集逐次日志、应用运行日志与操作审计</p>
</div>
<div class="actions">
<button class="btn" id="btnTail" type="button">刷新应用日志</button>
</div>
</div>
{% if detail %}
<section class="card">
<div class="cardhead">
<h2>运行 #{{ detail.id }} 详情</h2>
<a class="btn ghost sm" href="{{ url_for('views.logs') }}">收起</a>
</div>
<table class="kv">
<tr><th>触发方式</th><td>{{ detail.trigger }}</td><th>状态</th>
<td>
{% if detail.status=='ok' %}<span class="tag ok">成功</span>
{% elif detail.status=='warn' %}<span class="tag warn">有警告</span>
{% elif detail.status=='running' %}<span class="tag info">进行中</span>
{% else %}<span class="tag bad">失败</span>{% endif %}
{% if detail.exit_code is not none %}<span class="tag mute">exit {{ detail.exit_code }}</span>{% endif %}
</td></tr>
<tr><th>开始</th><td class="mono">{{ detail.started_at }}</td><th>结束</th><td class="mono">{{ detail.finished_at or '—' }}</td></tr>
<tr><th>同步区间</th><td colspan="3" class="mono">{{ detail.win_from or '—' }} ~ {{ detail.win_to or '' }}</td></tr>
<tr><th>云端返回</th><td>{{ detail.fetched }}</td><th>新增 / 重复</th><td>{{ detail.added }} / {{ detail.dup }}</td></tr>
<tr><th>入库总数</th><td>{{ detail.total }}</td><th>时间冲突</th><td>{{ detail.conflicts }}</td></tr>
<tr><th>结论</th><td colspan="3">{{ detail.message or '—' }}</td></tr>
</table>
<pre class="logbox" style="margin-top:12px">{{ detail.detail or '(无日志)' }}</pre>
</section>
{% endif %}
<section class="card">
<div class="cardhead">
<h2>采集运行日志</h2>
<div class="seg">
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if not status }}">全部</a>
<a href="{{ url_for('views.logs', status='ok') }}" class="{{ 'on' if status=='ok' }}">成功</a>
<a href="{{ url_for('views.logs', status='warn') }}" class="{{ 'on' if status=='warn' }}">警告</a>
<a href="{{ url_for('views.logs', status='error') }}" class="{{ 'on' if status=='error' }}">失败</a>
</div>
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>#</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
<th class="num">新增</th><th class="num">重复</th><th class="num">冲突</th><th>结论</th><th></th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.id }}</td>
<td class="mono nowrap">{{ r.started_at[5:] if r.started_at else '—' }}</td>
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
<td class="nowrap">
{% if r.status=='ok' %}<span class="tag ok">成功</span>
{% elif r.status=='warn' %}<span class="tag warn">有警告</span>
{% elif r.status=='running' %}<span class="tag info">进行中</span>
{% else %}<span class="tag bad">失败</span>{% endif %}
</td>
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
<td class="num">{{ r.added }}</td><td class="num">{{ r.dup }}</td>
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
<td>{{ r.message or '—' }}</td>
<td><a href="{{ url_for('views.logs', run=r.id, status=status or none) }}">详情</a></td>
</tr>
{% else %}
<tr><td colspan="10" class="empty">暂无采集日志</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
{# 翻页时保留 status/act,否则一翻页筛选条件就丢了 #}
<div class="pager">
{% if page > 1 %}<a href="{{ url_for('views.logs', page=page-1, status=status or none, act=act or none) }}">‹</a>{% endif %}
{% for p in page_window %}
{% if p == page %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.logs', page=p, status=status or none, act=act or none) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if page < pages %}<a href="{{ url_for('views.logs', page=page+1, status=status or none, act=act or none) }}">›</a>{% endif %}
<span class="meta">第 {{ page }} / {{ pages }} 页 · 共 {{ '{:,}'.format(total) }} 次</span>
</div>
{% endif %}
</section>
<div class="grid2">
<section class="card">
<div class="cardhead">
<h2>应用日志</h2>
<span class="hint" id="tailMeta"></span>
</div>
<pre class="logbox" id="appLog">正在加载 logs/app.log …</pre>
</section>
<section class="card">
<div class="cardhead">
<h2>操作审计</h2>
<span class="hint">共 {{ '{:,}'.format(atotal) }} 条 · 第 {{ apage }} / {{ apages }} 页</span>
</div>
{% if actions %}
<div class="seg" style="margin-bottom:10px">
<a href="{{ url_for('views.logs', status=status or none) }}" class="{{ 'on' if not act }}">全部</a>
{% for a in actions[:6] %}
<a href="{{ url_for('views.logs', act=a[0], status=status or none) }}"
class="{{ 'on' if act==a[0] }}">{{ a[0] }} <span class="muted">{{ a[1] }}</span></a>
{% endfor %}
</div>
{% endif %}
<div class="tablewrap scroll-y">
<table class="tbl" id="auditTable">
<thead><tr><th>时间</th><th>用户</th><th>动作</th><th>说明</th><th>IP</th></tr></thead>
<tbody>
{% for a in audits %}
<tr><td class="mono nowrap">{{ a.at[5:] if a.at else '' }}</td>
<td>{{ a.actor or '—' }}</td>
<td><span class="tag mute">{{ a.action }}</span></td>
<td>{{ a.detail or '—' }}</td><td class="mono">{{ a.ip or '' }}</td></tr>
{% else %}
<tr><td colspan="5" class="empty">暂无审计记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if apages > 1 %}
<div class="pager">
{% if apage > 1 %}
<a href="{{ url_for('views.logs', apage=apage-1, act=act or none, status=status or none) }}">‹</a>
{% endif %}
{% for p in apage_window %}
{% if p == apage %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.logs', apage=p, act=act or none, status=status or none) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if apage < apages %}
<a href="{{ url_for('views.logs', apage=apage+1, act=act or none, status=status or none) }}">›</a>
{% endif %}
<span class="meta">共 {{ '{:,}'.format(atotal) }} 条</span>
</div>
{% endif %}
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindTail('#btnTail', '/logs/tail', '#appLog', '#tailMeta');
</script>
{% endblock %}
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}概览 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>概览</h1>
<p class="lead">存档 {{ '{:,}'.format(totals.records) }} 条记录,覆盖 {{ totals.firstDay }} ~ {{ totals.lastDay }}</p>
</div>
<div class="actions">
<a class="btn" href="{{ url_for('views.dashboard') }}">打开用量大屏</a>
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
</div>
</div>
<div id="collectMsg" class="flash" style="display:none"></div>
<div class="kpis">
<div class="kpi" style="--c:var(--cyan)">
<span>累计积分</span><b>{{ '%.2f'|format(totals.credits) }}</b>
<i>{{ totals.days }} 个活跃日 · 单次均价 {{ '%.2f'|format(totals.credits / totals.calls if totals.calls else 0) }}</i>
</div>
<div class="kpi" style="--c:var(--violet)">
<span>调用次数</span><b>{{ '{:,}'.format(totals.calls) }}</b>
<i>计费 {{ '{:,}'.format(totals.billableCalls) }} · 免费 {{ '{:,}'.format(totals.freeCalls) }}
({{ '%.1f'|format(totals.freeCalls / totals.calls * 100 if totals.calls else 0) }}%)</i>
</div>
<div class="kpi" style="--c:var(--green)">
<span>今日积分</span><b>{{ '%.2f'|format(today_stat.credits) }}</b>
<i>{{ today_stat.calls }} 次调用{% if today_stat.partial %} · 残日截至 {{ today_stat.partial.hhmm }}{% endif %}
{% if yesterday.credits %}
· 昨日整日 {{ '%.2f'|format(yesterday.credits) }}
{% endif %}</i>
</div>
<div class="kpi" style="--c:var(--amber)">
<span>近 30 天积分</span><b>{{ '%.2f'|format(stat30.credits) }}</b>
<i>{% if stat30.delta %}<span class="delta {{ 'dn' if stat30.delta.credits < 0 }}">环比 {{ '%+.1f'|format(stat30.delta.credits) }}%</span>
· 前一区间 {{ '%.0f'|format(stat30.prev.credits) }}{% else %}无可比区间{% endif %}</i>
</div>
<div class="kpi" style="--c:var(--blue)">
<span>模型 / 客户端</span><b>{{ totals.models }} / {{ totals.clients }}</b>
<i>按积分排序见下表</i>
</div>
<div class="kpi" style="--c:{{ 'var(--green)' if mf.health.cookie else 'var(--red)' }}">
<span>采集健康</span><b>{{ '正常' if mf.health.cookie else '缺 Cookie' }}</b>
<i>{% if mf.health.lastRunAt %}最近采集 {{ mf.health.lastRunAt[5:16] }}{% else %}尚无采集记录{% endif %}</i>
</div>
</div>
<div class="grid2">
<section class="card">
<h2>调度状态</h2>
<table class="kv">
<tr><th>调度线程</th><td>{% if sch.running %}<span class="tag ok">运行中</span> 每 {{ sch.interval }}s 轮询{% else %}<span class="tag bad">未运行</span>{% endif %}</td></tr>
<tr><th>调度开关</th><td>{% if sch.enabled %}<span class="tag ok">已启用</span>{% else %}<span class="tag bad">已停用</span>{% endif %}</td></tr>
<tr><th>每日时刻</th><td>{{ sch.times | join(' · ') if sch.times else '—' }}{% if sch.catch_up %} <span class="tag">含启动补跑</span>{% endif %}</td></tr>
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
<tr><th>采集互斥锁</th><td>{% if sch.lock %}<span class="tag warn">采集中</span>{% else %}<span class="tag ok">空闲</span>{% endif %}</td></tr>
<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 进程内运行,不再需要计划任务或外部自动化。所有时刻与开关都在
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。</p>
</section>
<section class="card">
<div class="cardhead">
<h2>模型消耗 TOP</h2>
<span class="hint">按积分降序,取前 {{ models|length }} 名</span>
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>模型</th><th class="num">调用</th><th class="num">积分</th><th class="num">单次均价</th><th class="num">免费占比</th></tr></thead>
<tbody>
{% for m in models %}
<tr><td>{{ m.name }}</td><td class="num">{{ m.calls }}</td>
<td class="num">{{ '%.2f'|format(m.credits) }}</td>
<td class="num">{{ '%.2f'|format(m.avgPerCall) }}</td>
<td class="num">{{ '%.0f%%'|format(m.freeRate * 100) }}</td></tr>
{% else %}
<tr><td colspan="5" class="empty">还没有数据,先点右上角「立即采集一次」</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">客户端构成:{% for c in clients %}{{ c.name }} {{ '%.1f%%'|format(c.credits / totals.credits * 100 if totals.credits else 0) }}{% if not loop.last %} · {% endif %}{% else %}—{% endfor %}</p>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>最近采集</h2>
<a class="btn ghost sm" href="{{ url_for('views.tasks') }}">查看全部</a>
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>#</th><th>触发</th><th>开始</th><th class="num">耗时</th><th class="num">云端</th>
<th class="num">新增</th><th class="num">重复</th><th class="num">冲突</th><th class="num">总数</th><th>结论</th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td><a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a></td>
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
<td class="mono">{{ r.started_at[5:] if r.started_at else '—' }}</td>
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
<td class="num">{{ r.fetched if r.fetched is not none else '—' }}</td>
<td class="num">{{ r.added }}</td>
<td class="num">{{ r.dup }}</td>
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
<td class="num">{{ r.total }}</td>
<td>{{ r.message or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="10" class="empty">还没有采集记录,点右上角「立即采集一次」</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% endblock %}
+169
查看文件
@@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}数据明细 · {{ project_title }}{% endblock %}
{% block body %}
{# 注意:模板里统一用 frm 而不是 from,避免与 Python 关键字混淆;
拼回 URL 时必须换成 HTTP 规范参数名 from/to,否则导出/翻页会丢筛选条件。 #}
{% set qs = {'from': f.frm, 'to': f.to, 'model': f.model, 'client': f.client,
'q': f.q, 'order': f.order, 'size': f.size} %}
{% set has_filter = f.frm or f.to or f.model or f.client or f.q %}
<div class="pagehead">
<div>
<h1>数据明细</h1>
<p class="lead">
共 {{ '{:,}'.format(data.total) }} 条匹配记录 · 合计
<b class="hl">{{ '%.2f'|format(data.credits) }}</b> 积分
</p>
</div>
<div class="actions">
<a class="btn ghost" href="{{ url_for('views.dashboard') }}">用量大屏</a>
<a class="btn primary" href="{{ url_for('views.records_export', **qs) }}">导出当前筛选 CSV</a>
</div>
</div>
<section class="card">
<div class="cardhead">
<h2>筛选</h2>
<div class="quick">
<button class="btn sm ghost" type="button" data-range="today">今日</button>
<button class="btn sm ghost" type="button" data-range="7d">近 7 天</button>
<button class="btn sm ghost" type="button" data-range="30d">近 30 天</button>
<button class="btn sm ghost" type="button" data-range="all">全部</button>
</div>
</div>
<form class="filters" id="formFilter" method="get" action="{{ url_for('views.records') }}">
<label>开始日期<input type="date" name="from" id="fFrom" value="{{ f.frm }}"></label>
<label>结束日期<input type="date" name="to" id="fTo" value="{{ f.to }}"></label>
<label>模型
<select name="model">
<option value="">全部</option>
{% for m in models %}<option value="{{ m }}" {{ 'selected' if f.model==m }}>{{ m }}</option>{% endfor %}
</select>
</label>
<label>客户端
<select name="client">
<option value="">全部</option>
{% for c in clients %}<option value="{{ c }}" {{ 'selected' if f.client==c }}>{{ c }}</option>{% endfor %}
</select>
</label>
<label>关键词
<input name="q" value="{{ f.q }}" placeholder="搜 Prompt / RequestID">
</label>
<label>排序
<select name="order">
<option value="ts_desc" {{ 'selected' if f.order=='ts_desc' }}>时间倒序</option>
<option value="ts" {{ 'selected' if f.order=='ts' }}>时间正序</option>
<option value="credits_desc" {{ 'selected' if f.order=='credits_desc' }}>积分从高到低</option>
<option value="credits" {{ 'selected' if f.order=='credits' }}>积分从低到高</option>
</select>
</label>
<label>每页
<select name="size">
{% for n in [20, 50, 100, 200, 500] %}
<option value="{{ n }}" {{ 'selected' if f.size==n }}>{{ n }}</option>
{% endfor %}
</select>
</label>
<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>
</section>
<section class="card">
<div class="cardhead">
<h2>记录列表</h2>
<span class="hint">
第 {{ data.page }} / {{ data.pages }} 页 · 每页 {{ data.size }} 条
{%- if data.total %} · 本页 {{ rows|length }} 条{% endif %}
</span>
</div>
{% if rows %}
<div class="tablewrap">
<table class="tbl">
<thead>
<tr><th class="num">#</th><th>时间</th><th class="num">积分</th><th>模型</th>
<th>客户端</th><th>RequestID</th><th>User Prompt</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="num muted">{{ (data.page - 1) * data.size + loop.index }}</td>
<td class="mono nowrap">{{ r.ts }}</td>
<td class="num {{ 'zero' if not r.credits }}">{{ '%.2f'|format(r.credits) }}</td>
<td class="nowrap">{{ r.model }}</td>
<td class="nowrap"><span class="tag mute">{{ r.client }}</span></td>
<td class="mono sm nowrap" title="{{ r.request_id }}">{{ r.request_id[:16] }}…</td>
<td class="px">
{% if r.prompt %}
<details>
<summary>{{ r.prompt[:70] }}{% if r.prompt|length > 70 %}…{% endif %}</summary>
<div class="fullprompt">{{ r.prompt }}</div>
</details>
{% else %}
<span class="hint">(空)</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<b>没有匹配的记录</b>
{% if has_filter %}当前筛选条件过窄,试试放宽日期区间或点「重置」。{% else %}存档里还没有数据,到「任务管理」手动采集一次。{% endif %}
</div>
{% endif %}
{% if data.pages > 1 %}
<div class="pager">
{% if data.page > 1 %}
<a href="{{ url_for('views.records', page=1, **qs) }}">«</a>
<a href="{{ url_for('views.records', page=data.page-1, **qs) }}">‹ 上一页</a>
{% endif %}
{% for p in page_window %}
{% if p == data.page %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.records', page=p, **qs) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if data.page < data.pages %}
<a href="{{ url_for('views.records', page=data.page+1, **qs) }}">下一页 ›</a>
<a href="{{ url_for('views.records', page=data.pages, **qs) }}">»</a>
{% endif %}
<span class="meta">第 {{ data.page }} / {{ data.pages }} 页 · 共 {{ '{:,}'.format(data.total) }} 条</span>
</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
// 快捷区间:只填表单里的两个日期输入框,真正的提交交给「查询」按钮,
// 避免按一下就把当前排序/每页设置冲掉。
(function () {
function ymd(d) { // 必须用本地字段拼串:toISOString 走 UTC,GMT+8 下会少一天
var m = d.getMonth() + 1, day = d.getDate();
return d.getFullYear() + "-" + (m < 10 ? "0" : "") + m + "-" + (day < 10 ? "0" : "") + day;
}
var from = document.getElementById("fFrom"), to = document.getElementById("fTo");
Array.prototype.forEach.call(document.querySelectorAll("[data-range]"), function (btn) {
btn.addEventListener("click", function () {
var v = btn.dataset.range, now = new Date();
if (v === "all") { from.value = ""; to.value = ""; }
else if (v === "today") { from.value = to.value = ymd(now); }
else {
var n = v === "7d" ? 6 : 29; // 含今天,所以是 N-1
from.value = ymd(new Date(now.getTime() - n * 86400000));
to.value = ymd(now);
}
from.focus();
});
});
})();
</script>
{% endblock %}
+128
查看文件
@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}任务管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>任务管理</h1>
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里也能手动触发与按区间回填</p>
</div>
<div class="actions">
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
</div>
</div>
<div id="collectMsg" class="flash" style="display:none"></div>
<div class="grid2">
<section class="card">
<h2>采集调度</h2>
<form id="formTask">
<label class="row"><span>启用调度</span>
<select name="schedule_enabled">
<option value="1" {{ 'selected' if sch.enabled }}>启用</option>
<option value="0" {{ 'selected' if not sch.enabled }}>停用</option>
</select>
</label>
<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>)。保存后立即生效,
并会清空当天已执行的槽位标记以便新时刻接管。</p>
<label class="row"><span>启动补跑</span>
<select name="catch_up">
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
<option value="0" {{ 'selected' if not sch.catch_up }}>关闭(只在到点时执行)</option>
</select>
</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>
</label>
<button class="btn primary" type="submit">保存调度配置</button>
</form>
</section>
<section class="card">
<h2>运行状态</h2>
<table class="kv">
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
<tr><th>调度线程</th><td>{% if sch.running %}<span class="tag ok">运行中</span>{% else %}<span class="tag bad">未运行</span>{% endif %}
<span class="hint">({{ sch.interval }}s 轮询)</span></td></tr>
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
<tr><th>互斥锁</th><td>{% if sch.lock %}<span class="tag warn">有采集在跑</span>{% else %}<span class="tag ok">空闲</span>{% endif %}</td></tr>
<tr><th>最近采集</th><td>{% if sch.last %}<span class="tag {{ 'ok' if sch.last.status=='ok' else ('warn' if sch.last.status=='warn' else 'bad') }}">#{{ sch.last.id }} {{ sch.last.status }}</span>
<span class="hint">{{ sch.last.message or '' }}</span>{% else %}—{% endif %}</td></tr>
</table>
<hr class="sect-divider">
<h3>历史回填</h3>
<form id="formBackfill">
<label class="row"><span>起始日期</span><input type="date" name="from" max="{{ sch.now[:10] }}"></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> 去重,不会重复计入。
区间越大耗时越长(云端按天分页拉取)。</p>
</form>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>采集运行历史</h2>
<span class="hint">共 {{ '{:,}'.format(total) }} 次</span>
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>#</th><th>触发</th><th>状态</th><th>开始</th><th class="num">耗时</th>
<th>同步区间</th><th class="num">云端</th><th class="num">新增</th><th class="num">重复</th>
<th class="num">冲突</th><th>结论</th><th></th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.id }}</td>
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
<td class="nowrap">
{% if r.status=='ok' %}<span class="tag ok">成功</span>
{% elif r.status=='warn' %}<span class="tag warn">有警告</span>
{% elif r.status=='running' %}<span class="tag info">进行中</span>
{% else %}<span class="tag bad">失败</span>{% endif %}
{% if r.exit_code %}<span class="tag mute">exit {{ r.exit_code }}</span>{% endif %}
</td>
<td class="mono nowrap">{{ r.started_at[5:] if r.started_at else '—' }}</td>
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
<td class="mono">{{ r.win_from or '—' }}<br>{{ r.win_to or '' }}</td>
<td class="num">{{ r.fetched }}</td>
<td class="num">{{ r.added }}</td>
<td class="num">{{ r.dup }}</td>
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
<td>{{ r.message or '—' }}</td>
<td><a href="{{ url_for('views.logs', run=r.id) }}">日志</a></td>
</tr>
{% else %}
<tr><td colspan="12" class="empty">暂无运行记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<div class="pager">
{% if page > 1 %}<a href="{{ url_for('views.tasks', page=page-1) }}">‹</a>{% endif %}
{% for p in page_window %}
{% if p == page %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.tasks', page=p) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if page < pages %}<a href="{{ url_for('views.tasks', page=page+1) }}">›</a>{% endif %}
<span class="meta">第 {{ page }} / {{ pages }} 页</span>
</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formTask', '/api/settings');
WBU.bindForm('#formBackfill', '/api/collect', {confirmText: '确定按该区间重新采集吗?区间较大时会耗时较久。'});
</script>
{% endblock %}
+162
查看文件
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}用户管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>用户管理</h1>
<p class="lead">门户在局域网可访问,因此必须靠账号隔离;这里维护账号、管理员身份与密码</p>
</div>
<div class="actions">
<span class="tag accent">仅管理员可见</span>
</div>
</div>
<div class="grid2">
<section class="card">
<h2>新建账号</h2>
<form id="formNewUser">
<label class="row"><span>用户名</span>
<input name="username" maxlength="32" placeholder="登录名(≤32 字符)" autocomplete="off" spellcheck="false"></label>
<label class="row"><span>显示名</span>
<input name="display_name" maxlength="64" placeholder="留空则与用户名相同"></label>
<label class="row"><span>密码</span>
<input name="password" type="password" autocomplete="new-password"></label>
<label class="row"><span>确认密码</span>
<input name="password2" type="password" autocomplete="new-password"></label>
<label class="row"><span>权限</span>
<select name="is_admin">
<option value="1">管理员(可管理用户)</option>
<option value="0">普通账号(只读数据与日志)</option>
</select>
</label>
<button class="btn primary" type="submit">创建账号</button>
<p class="hint">密码至少 6 位、最多 128 位。普通账号不能用本页,也调不动用户管理接口。</p>
</form>
</section>
<section class="card">
<div class="cardhead">
<h2>账号列表</h2>
<span class="hint">共 {{ users|length }} 个</span>
</div>
<div class="tablewrap">
<table class="tbl" id="userTable">
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限</th>
<th>最后登录</th><th class="num">次数</th><th>操作</th></tr></thead>
<tbody>
{% for u in users %}
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}">
<td class="num muted">{{ u.id }}</td>
<td class="nowrap"><b>{{ u.username }}</b>
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}</td>
<td><input class="inp inp-sm" name="display_name" maxlength="64"
value="{{ u.display_name or '' }}" spellcheck="false"></td>
<td>
{# 不能取消自己的管理员身份,所以本人的下拉直接禁用(服务端也会再拦一次) #}
<select class="inp inp-sm" name="is_admin" {{ 'disabled' if u.id == me.id }}>
<option value="1" {{ 'selected' if u.is_admin }}>管理员</option>
<option value="0" {{ 'selected' if not u.is_admin }}>普通</option>
</select>
</td>
<td class="mono sm nowrap">{{ u.last_login_at or '—' }}</td>
<td class="num">{{ u.login_count }}</td>
<td class="nowrap">
<button class="btn sm" type="button" data-act="save">保存</button>
<button class="btn sm ghost" type="button" data-act="pwd">改密</button>
{% if u.id != me.id %}
<button class="btn sm danger" type="button" data-act="del">删除</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">还没有账号</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">「改密」会依次询问新密码与确认;管理员不能取消自己的管理员身份,任何人也不能删除自己。</p>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>用户操作审计</h2>
<span class="hint">最近 20 条</span>
</div>
<div class="tablewrap scroll-y">
<table class="tbl">
<thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>说明</th><th>IP</th></tr></thead>
<tbody>
{% for a in audits %}
<tr>
<td class="mono sm nowrap">{{ a.at[5:] if a.at else '' }}</td>
<td>{{ a.actor or '—' }}</td>
<td><span class="tag mute">{{ a.action }}</span></td>
<td>{{ a.detail or '—' }}</td>
<td class="mono sm">{{ a.ip or '' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">暂无用户操作记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formNewUser', '/api/users', {
validate: function (d) {
if (!d.username) return '用户名必填';
return d.password === d.password2 ? null : '两次输入的密码不一致';
}
});
// 行内操作走事件委托:表格动态渲染也不会丢绑定
document.getElementById('userTable').addEventListener('click', function (e) {
var btn = e.target.closest('button[data-act]');
if (!btn) return;
var row = btn.closest('tr'), uid = row.dataset.uid, name = row.dataset.name;
var act = btn.dataset.act;
var old = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
function done(p) {
p.then(function (j) {
if (j.ok === false) { WBU.say(j.message || '操作失败', 'error'); return; }
WBU.say(j.message || '已保存', 'ok');
window.setTimeout(function () { location.reload(); }, 900);
}).catch(function (err) {
if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error');
}).finally(function () { btn.disabled = false; btn.textContent = old; });
}
if (act === 'save') {
var fields = {};
fields.display_name = row.querySelector('[name=display_name]').value;
var sel = row.querySelector('[name=is_admin]');
if (sel && !sel.disabled) fields.is_admin = sel.value;
done(WBU.post('/api/users/' + uid, fields));
} else if (act === 'pwd') {
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 6 位)');
if (p1 === null) { btn.disabled = false; btn.textContent = old; return; }
var p2 = window.prompt('再输入一次新密码以确认');
if (p2 === null) { btn.disabled = false; btn.textContent = old; return; }
if (p1 !== p2) { WBU.say('两次输入的密码不一致', 'warn'); btn.disabled = false; btn.textContent = old; return; }
done(WBU.post('/api/users/' + uid, { password: p1, password2: p2 }));
} else if (act === 'del') {
if (!window.confirm('确定删除用户「' + name + '」?该操作不可撤销(其历史审计记录会保留)。')) {
btn.disabled = false; btn.textContent = old; return;
}
done(WBU.post('/api/users/' + uid + '/delete', {}));
} else {
btn.disabled = false; btn.textContent = old;
}
});
</script>
{% endblock %}