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 行删除
+193
查看文件
@@ -0,0 +1,193 @@
/* 门户页面的轻量交互:统一带 CSRF 的请求、表单提交、日志尾部加载、维护动作 */
(function () {
"use strict";
function token() {
return window.WB_CSRF || "";
}
// 所有提示都先转义再插入:服务端 message 里可能带用户输入(用户名、文件名),
// 直接 innerHTML 等于给它一个 XSS 入口。
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function flashBox() {
var box = document.getElementById("collectMsg");
if (!box) {
box = document.createElement("div");
box.id = "collectMsg";
box.className = "flash";
var head = document.querySelector(".pagehead");
(head ? head.parentNode : document.body).insertBefore(box, head ? head.nextSibling : null);
}
return box;
}
// say(文本, 类别):文本按纯文本渲染,\n 转成换行
function say(msg, cat) {
var box = flashBox();
box.style.display = "block";
box.className = "flash " + (cat || "");
box.innerHTML = esc(msg).replace(/\n/g, "<br>");
if (cat !== "warn") {
window.clearTimeout(say._t);
say._t = window.setTimeout(function () { box.style.display = "none"; }, 12000);
}
}
function post(url, data, opts) {
opts = opts || {};
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": token() },
body: JSON.stringify(data || {})
}).then(function (r) {
return r.json().catch(function () { return { ok: false, message: "响应不是 JSON(HTTP " + r.status + ")" }; })
.then(function (j) { return { status: r.status, body: j }; });
}).then(function (res) {
if (res.status === 401) {
say("登录已失效,即将跳转登录页…", "warn");
window.setTimeout(function () { location.href = "/login?next=" + encodeURIComponent(location.pathname); }, 1200);
throw new Error("unauthorized");
}
return res.body;
});
}
function formData(form) {
var out = {};
Array.prototype.forEach.call(form.elements, function (el) {
if (!el.name || el.type === "submit" || el.name === "_csrf") return;
if (el.type === "checkbox") { out[el.name] = el.checked ? "1" : "0"; return; }
if (el.type === "radio") { if (el.checked) out[el.name] = el.value; return; }
out[el.name] = el.value;
});
return out;
}
// 失败提示的公共尾巴:把「下一步怎么办」直接告诉用户
function hintOf(j) {
if (!j) return "";
if (j.error === "cookie_expired") return "\n请到「配置管理」更新 Cookie 与 User-Agent(两者须取自同一次浏览器请求)。";
if (j.error === "busy") return "\n已有采集在进行,可稍后重试,或到「日志管理」查看进度。";
if (j.errors && j.errors.length) return "\n" + j.errors.join("\n");
return "";
}
// 通用表单绑定:提交到指定 API,成功后按 reload 与否决定刷新
function bindForm(sel, url, opts) {
opts = opts || {};
var form = document.querySelector(sel);
if (!form) return;
form.addEventListener("submit", function (e) {
e.preventDefault();
var d = formData(form);
if (opts.validate) {
var err = opts.validate(d);
if (err) { say(err, "warn"); return; }
}
if (opts.confirmText && !window.confirm(opts.confirmText)) return;
var btn = form.querySelector("button[type=submit]");
if (btn) { btn.disabled = true; btn.dataset.t = btn.textContent; btn.textContent = "处理中…"; }
var isCollect = url.indexOf("/collect") >= 0;
var req = isCollect ? post(url, { from: d.from || null, to: d.to || null }) : post(url, d);
req.then(function (j) {
if (j.ok === false) { say((j.message || "操作失败") + hintOf(j), "error"); return; }
if (isCollect && j.result) {
var r = j.result;
say("采集完成:云端返回 " + r.fetched + " 条,新增 " + r.added + " 条,重复 " + r.dup +
" 条,存档共 " + r.total + " 条。" + (r.conflicts ? "(有 " + r.conflicts + " 条时间漂移警告)" : ""),
r.conflicts ? "warn" : "ok");
} else {
say("已保存" + (j.changed && j.changed.length ? ":" + j.changed.join("、") : ""), "ok");
}
if (opts.reload !== false) window.setTimeout(function () { location.reload(); }, 900);
}).catch(function (e) {
if (String(e.message) !== "unauthorized") say("请求失败:" + e.message, "error");
}).finally(function () {
if (btn) { btn.disabled = false; btn.textContent = btn.dataset.t; }
});
});
}
function bindTail(btnSel, url, boxSel, metaSel) {
var btn = document.querySelector(btnSel);
if (!btn) return;
function load() {
btn.disabled = true;
fetch(url + "?lines=400", { headers: { "X-Requested-With": "fetch" } })
.then(function (r) { return r.json(); })
.then(function (j) {
var box = document.querySelector(boxSel);
box.textContent = (j.lines && j.lines.length) ? j.lines.join("\n") : "(日志为空)";
box.scrollTop = box.scrollHeight;
var meta = document.querySelector(metaSel);
if (meta) meta.textContent = j.path + " · " + (j.size / 1024).toFixed(1) + " KB";
})
.catch(function (e) { say("日志加载失败:" + e.message, "error"); })
.finally(function () { btn.disabled = false; });
}
btn.addEventListener("click", load);
load();
}
// 顶栏/页头的「立即采集一次」按钮
function bindCollect(sel) {
var btn = document.querySelector(sel || "#btnCollect");
if (!btn) return;
btn.addEventListener("click", function () {
btn.disabled = true;
var old = btn.textContent;
btn.textContent = "采集中…";
post("/api/collect", {})
.then(function (j) {
if (j.ok === false) { say((j.message || "采集失败") + hintOf(j), "error"); return; }
var r = j.result;
say("采集完成:云端返回 " + r.fetched + " 条,新增 " + r.added + " 条,重复 " + r.dup +
" 条,存档共 " + r.total + " 条。" + (r.conflicts ? "(" + r.conflicts + " 条时间漂移警告)" : ""),
r.conflicts ? "warn" : "ok");
window.setTimeout(function () { location.reload(); }, 1100);
})
.catch(function (e) { if (String(e.message) !== "unauthorized") say("请求失败:" + e.message, "error"); })
.finally(function () { btn.disabled = false; btn.textContent = old; });
});
}
// 维护动作:补全 Prompt / 导出全量 CSV / 整理数据库
var MAINT = {
"fill-prompt": { label: "补全缺失 Prompt", confirm: "补全 Prompt 需要联网并逐天重拉云端,可能耗时较久。继续?" },
"export-csv": { label: "导出全量 CSV" },
"vacuum": { label: "整理数据库", confirm: "将执行 checkpoint + VACUUM,期间数据库会短暂锁定。继续?" },
"recount": { label: "重新计数" }
};
function bindMaint(sel) {
Array.prototype.forEach.call(document.querySelectorAll(sel || "[data-maint]"), function (btn) {
btn.addEventListener("click", function () {
var act = btn.dataset.maint;
var meta = MAINT[act] || { label: act };
if (meta.confirm && !window.confirm(meta.confirm)) return;
btn.disabled = true;
var old = btn.textContent;
btn.textContent = "处理中…";
post("/api/maintenance/" + encodeURIComponent(act), {})
.then(function (j) {
if (j.ok === false) { say((j.message || (meta.label + " 失败")) + hintOf(j), "error"); return; }
say(j.message || (meta.label + " 完成"), "ok");
window.setTimeout(function () { location.reload(); }, 1500);
})
.catch(function (e) { if (String(e.message) !== "unauthorized") say("请求失败:" + e.message, "error"); })
.finally(function () { btn.disabled = false; btn.textContent = old; });
});
});
}
window.WBU = {
post: post, bindForm: bindForm, bindTail: bindTail,
bindCollect: bindCollect, bindMaint: bindMaint, say: say, esc: esc
};
document.addEventListener("DOMContentLoaded", function () { bindCollect("#btnCollect"); });
})();