feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码

数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
  索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
  (漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落

凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
  encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
  secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文

注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效

页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停

修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量

验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
这个提交包含在:
2026-09-15 17:32:35 +08:00
父节点 23799b4ea5
当前提交 df7db3582e
共修改 46 个文件,包含 4348 行新增和 953 行删除
+13 -5
查看文件
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="robots" content="noindex, nofollow">
<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') }}">
@@ -11,7 +12,11 @@
<body>
{% set nav = active|default('') %}
{% set on_dash = request.path.startswith('/dashboard') %}
{% if current_user() %}
{# 变量名刻意叫 cur 而不是 me:模板里的 {% set %} 会覆盖子模板传入的同名变量,
而 current_user() 只含 id/username/display_name/is_admin —— 曾因此让
个人中心把 me.created_at 渲染成空(子模板的 me 是完整的用户行)。 #}
{% set cur = current_user() %}
{% if cur %}
<header class="topbar">
<div class="brand">
<span class="dot"></span>
@@ -25,12 +30,15 @@
<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 %}
{% if cur.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>
<a class="who" href="{{ url_for('views.profile_page') }}" title="个人中心">
<b>{{ cur.display_name }}</b>
{% if cur.is_admin %}<span class="tag accent">管理员</span>{% endif %}
</a>
{# 退出用 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() }}">
@@ -52,8 +60,8 @@
</main>
<footer class="foot">
<b>{{ project_title }}</b> · {{ project_name }} · 采集 / 存储 / 呈现三合一 · 数据正本 <code>data/usage.sqlite</code><br>
采集在 Web 进程内按配置时刻执行,无需外部计划任务
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 采集在 Web 进程内按各账号配置的时刻执行<br>
每个账号只使用并只见得到自己的 Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
</footer>
<script>
+75 -10
查看文件
@@ -5,24 +5,35 @@
<div class="pagehead">
<div>
<h1>配置管理</h1>
<p class="lead">凭证、采集参数、维护动作都在这里;所有配置存在数据库,改完立即生效</p>
<p class="lead">这里改的都是<b>你自己账号</b>的配置:凭证、采集参数、维护动作;改完立即生效</p>
</div>
</div>
{% if s.cookie_broken %}
<div class="flash error" style="margin-bottom:16px">
已保存的 Cookie <b>无法解密</b>(通常是 <code>data/instance.json</code> 里的
<code>cookie_key</code> 被更换或文件丢失)。请重新粘贴一次;在此之前该账号的采集会失败。
</div>
{% endif %}
<section class="card">
<div class="cardhead">
<h2>云端凭证</h2>
<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 %}
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}
{% 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>:采集只使用本人凭证,各账号的数据互不可见。
</p>
<form id="formCred">
<label class="col">Cookie
<textarea name="cookie" rows="4" placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie" spellcheck="false"></textarea>
<textarea name="cookie" rows="4" autocomplete="off" spellcheck="false"
placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie"></textarea>
</label>
<label class="col">User-Agent
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
@@ -35,8 +46,15 @@
<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>
{# 实例级键:所有账号共用,只有管理员能改;普通账号只读展示 #}
<label class="row"><span>接口基址</span>
<input name="api_base" value="{{ s.api_base }}" spellcheck="false"
{{ '' if is_admin else 'disabled' }}>
{% if not is_admin %}<em class="unit">实例级,仅管理员可改</em>{% endif %}</label>
<label class="row"><span>接口路径</span>
<input name="api_path" value="{{ s.api_path }}" spellcheck="false"
{{ '' if is_admin else 'disabled' }}>
{% if not is_admin %}<em class="unit">实例级</em>{% endif %}</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 }}">
@@ -75,7 +93,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">至少 6 位。修改成功后当前会话仍有效,不必重新登录。</p>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
修改成功后当前会话仍有效,不必重新登录。</p>
</form>
<hr class="sect-divider">
@@ -84,18 +103,62 @@
<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>
title="导出与官网 xlsx 同构的 CSV 到 data/exports/">导出我的 CSV</button>
{% if is_admin %}
<button class="btn" type="button" data-maint="vacuum"
title="checkpoint + VACUUM,回收删除后的空闲页">整理数据库</button>
title="checkpoint + VACUUM,回收删除后的空闲页(整库操作,仅管理员)">整理数据库</button>
{% endif %}
</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 %}
<a class="btn ghost" href="{{ url_for('views.profile_page') }}">个人中心</a>
{% if is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
</div>
</section>
</div>
{% if is_admin %}
<section class="card">
<div class="cardhead">
<h2>实例级设置</h2>
<span class="tag accent">仅管理员可改,对所有账号生效</span>
</div>
<form id="formGlobal">
{% set b = num_settings %}
<label class="row"><span>开放自助注册</span>
<select name="allow_register">
<option value="1" {{ 'selected' if s.allow_register != '0' }}>允许任何人注册</option>
<option value="0" {{ 'selected' if s.allow_register == '0' }}>关闭注册(只能由管理员建号)</option>
</select>
</label>
<label class="row"><span>同 IP 每日注册上限</span>
<input name="register_max_per_ip" type="number"
min="{{ b.register_max_per_ip[0] }}" max="{{ b.register_max_per_ip[1] }}"
value="{{ s.register_max_per_ip }}">
<em class="unit">{{ b.register_max_per_ip[0] }}~{{ b.register_max_per_ip[1] }} 个/天</em></label>
<label class="row"><span>验证码策略</span>
<select name="captcha_policy">
<option value="always" {{ 'selected' if s.captcha_policy == 'always' }}>始终要求(推荐)</option>
<option value="adaptive" {{ 'selected' if s.captcha_policy == 'adaptive' }}>仅连续失败后要求(对日常更友好)</option>
<option value="off" {{ 'selected' if s.captcha_policy == 'off' }}>关闭(不推荐)</option>
</select>
</label>
<label class="row"><span>验证码位数</span>
<input name="captcha_length" type="number"
min="{{ b.captcha_length[0] }}" max="{{ b.captcha_length[1] }}"
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>
</form>
</section>
{% endif %}
{% endblock %}
{% block scripts %}
@@ -104,6 +167,8 @@
WBU.bindForm('#formCred', '/api/settings');
WBU.bindForm('#formCollect', '/api/settings');
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
var fg = document.querySelector('#formGlobal');
if (fg) WBU.bindForm('#formGlobal', '/api/settings');
WBU.bindMaint('[data-maint]');
</script>
{% endblock %}
+16 -1
查看文件
@@ -15,9 +15,24 @@
<label>密码
<input name="password" type="password" autocomplete="current-password" required>
</label>
{% if need_captcha %}
<label>验证码
<span class="caprow">
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
required placeholder="不区分大小写">
{# 点击换一张:URL 带时间戳,避免浏览器复用已被消费的旧图 #}
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
src="{{ url_for('views.captcha_png', purpose='login') }}&t={{ range(1000000)|random }}"
onclick="this.src='{{ url_for('views.captcha_png', purpose='login') }}&t=' + Date.now();">
</span>
</label>
{% endif %}
<button class="btn primary" type="submit">登 录</button>
{% if allow_register %}
<p class="foot-note">还没有账号?<a href="{{ url_for('views.register') }}">自助注册</a></p>
{% endif %}
<p class="foot-note">
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即到「配置管理」修改密码。<br>
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即修改密码。<br>
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
</p>
</form>
+103
查看文件
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}个人中心 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>个人中心</h1>
<p class="lead">账号 <b>{{ me.username }}</b> · 注册于 {{ (me.created_at or '')[:16] }} ·
最近登录 {{ (me.last_login_at or '未登录')[:19] }}</p>
</div>
<div class="actions">
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
</div>
</div>
<div class="kpis">
<div class="kpi" style="--c:var(--cyan)">
<span>我的记录</span><b>{{ '{:,}'.format(my.n or 0) }}</b>
<i>{{ my.d0 or '—' }} ~ {{ my.d1 or '—' }}</i>
</div>
<div class="kpi" style="--c:var(--violet)">
<span>我的积分</span><b>{{ '%.2f'|format(my.c or 0) }}</b>
<i>仅统计归属本账号的数据</i>
</div>
<div class="kpi" style="--c:var(--blue)">
<span>采集次数</span><b>{{ '{:,}'.format(runs) }}</b>
<i>{% if sch.last %}最近 {{ sch.last.started_at[5:16] if sch.last.started_at else '—' }}{% else %}尚无采集{% endif %}</i>
</div>
<div class="kpi" style="--c:{{ 'var(--green)' if cred.set and not cred.broken else 'var(--red)' }}">
<span>我的 Cookie</span>
<b>{% if cred.broken %}无法解密{% elif cred.set %}已配置{% else %}未配置{% endif %}</b>
<i>{% if cred.set and not cred.broken %}{{ cred.chars }} 字符,结尾 …{{ cred.tail }}
{%- elif cred.broken %}实例密钥被更换,请重新粘贴
{%- else %}采集需要本人凭证{% endif %}</i>
</div>
</div>
<div class="grid2">
<section class="card">
<h2>修改资料</h2>
<form id="formProfile">
<label class="row"><span>用户名</span>
<input value="{{ me.username }}" disabled spellcheck="false">
<em class="unit">登录名不可改</em></label>
<label class="row"><span>显示名</span>
<input name="display_name" maxlength="64" value="{{ me.display_name or '' }}" spellcheck="false"></label>
<label class="row"><span>邮箱</span>
<input name="email" type="email" maxlength="128" value="{{ me.email or '' }}" spellcheck="false"></label>
<button class="btn primary" type="submit">保存资料</button>
</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">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
修改成功后当前会话仍有效,不必重新登录。</p>
</form>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>我的采集凭证</h2>
<span class="tag {{ 'ok' if cred.set and not cred.broken else ('bad' if not cred.set else 'warn') }}">
{{ '正常' if cred.set and not cred.broken else ('未配置' if not cred.set else '需要重配') }}</span>
</div>
<table class="kv">
<tr><th>状态</th><td>
{% if cred.broken %}<span class="tag bad">已保存但无法解密</span>,请到「配置管理」重新粘贴
{% elif cred.set %}<span class="tag ok">已保存(密文入库)</span>
{% else %}<span class="tag bad">未配置</span>{% endif %}
</td></tr>
<tr><th>字符数 / 尾部</th><td class="mono">{{ cred.chars or 0 }} / {{ ('…' + cred.tail) if cred.tail else '—' }}</td></tr>
<tr><th>最后更新</th><td class="mono">{{ cred.at or '—' }}</td></tr>
<tr><th>调度</th><td>
{% if sch.enabled %}{{ sch.times | join(' · ') or '未设置时刻' }}{% else %}<span class="tag bad">已停用</span>{% endif %}
{% if sch.next_run %}· 下次 <span class="mono">{{ sch.next_run }}</span>{% endif %}
</td></tr>
</table>
<p class="hint">
凭证以密文形式存在数据库里(主密钥在 <code>data/instance.json</code>),
页面与接口**任何时候都不回传明文**,只显示长度与尾部 4 位。要更换请到
<a href="{{ url_for('views.config_page') }}">配置管理</a>粘贴新的 Cookie 与 User-Agent
(两者必须取自同一次浏览器请求)。
</p>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formProfile', '/api/profile');
WBU.bindForm('#formPwd', '/api/password', {
validate: function (d) { return d.new === d.new2 ? null : '两次输入的新密码不一致'; }
});
</script>
{% endblock %}
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}注册 · {{ project_title }}{% endblock %}
{% block body %}
<div class="loginwrap">
<form class="card login wide" method="post" action="{{ url_for('views.register') }}">
<div class="logo">W</div>
<h1>注册 {{ project_title }}</h1>
<p class="hint">注册后请粘贴<strong>你自己账号</strong>的 Cookie —— 采集只使用本人的凭证,各账号数据互相隔离。</p>
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<label>用户名 <em class="unit">3~32 位,字母或数字开头</em>
<input name="username" value="{{ username or '' }}" autocomplete="username"
autofocus required maxlength="32" spellcheck="false">
</label>
<label>显示名 <em class="unit">留空则与用户名相同</em>
<input name="display_name" value="{{ display_name or '' }}" maxlength="64" autocomplete="nickname">
</label>
<label>邮箱 <em class="unit">选填,便于日后找回</em>
<input name="email" type="email" value="{{ email or '' }}" maxlength="128" autocomplete="email">
</label>
<label>密码 <em class="unit">至少 {{ pwd_min }} 位,需含两类以上字符</em>
<input name="password" type="password" autocomplete="new-password" required>
</label>
<label>确认密码
<input name="password2" type="password" autocomplete="new-password" required>
</label>
{% if need_captcha %}
<label>验证码
<span class="caprow">
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
required placeholder="不区分大小写">
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
src="{{ url_for('views.captcha_png', purpose='register') }}&t={{ range(1000000)|random }}"
onclick="this.src='{{ url_for('views.captcha_png', purpose='register') }}&t=' + Date.now();">
</span>
</label>
{% endif %}
<button class="btn primary" type="submit">注 册</button>
<p class="foot-note">已有账号?<a href="{{ url_for('views.login') }}">返回登录</a></p>
<p class="foot-note">
注册受来源限额与图形验证码双重保护;同一来源每天可注册的账号数由管理员设定。<br>
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟。
</p>
</form>
</div>
{% endblock %}
+53 -21
查看文件
@@ -5,10 +5,12 @@
<div class="pagehead">
<div>
<h1>用户管理</h1>
<p class="lead">门户在局域网可访问,因此必须靠账号隔离;这里维护账号、管理员身份与密码</p>
<p class="lead">维护账号、状态与管理员身份。单价数据<b>按账号隔离</b>——管理员也看不到别人的用量与凭证</p>
</div>
<div class="actions">
<span class="tag accent">仅管理员可见</span>
<span class="tag {{ 'ok' if allow_register else 'mute' }}">
自助注册:{{ '已开放' if allow_register else '已关闭' }}</span>
</div>
</div>
@@ -17,21 +19,25 @@
<h2>新建账号</h2>
<form id="formNewUser">
<label class="row"><span>用户名</span>
<input name="username" maxlength="32" placeholder="登录名(≤32 字符)" autocomplete="off" spellcheck="false"></label>
<input name="username" maxlength="32" placeholder="3~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="email" type="email" maxlength="128" 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>
<option value="0">普通账号(只管自己的凭证与数据)</option>
<option value="1">管理员(可管理用户与实例设置)</option>
</select>
</label>
<button class="btn primary" type="submit">创建账号</button>
<p class="hint">密码至少 6 位、最多 128 位。普通账号不能用本页,也调不动用户管理接口。</p>
<p class="hint">密码至少 8 位且需含两类以上字符。新账号默认是<b>普通账号</b>;
管理员身份请显式选择。无论哪种身份,都需要各自配置自己的 Cookie 才能采集。</p>
</form>
</section>
@@ -42,29 +48,39 @@
</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>
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限 / 状态</th>
<th class="num">我的数据</th><th>最后登录</th><th>操作</th></tr></thead>
<tbody>
{% for u in users %}
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}">
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}"
data-status="{{ u.status or 'active' }}">
<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>
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}
{% if u.email %}<br><span class="muted sm">{{ u.email }}</span>{% endif %}</td>
<td><input class="inp inp-sm" name="display_name" maxlength="64"
value="{{ u.display_name or '' }}" spellcheck="false"></td>
<td>
{# 不能取消自己的管理员身份,所以本人的下拉直接禁用(服务端也会再拦一次) #}
<td class="nowrap">
{# 不能取消自己的管理员身份,也不能停用自己(服务端也会再拦一次) #}
<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>
<select class="inp inp-sm" name="status" {{ 'disabled' if u.id == me.id }}>
<option value="active" {{ 'selected' if (u.status or 'active') == 'active' }}>启用</option>
<option value="disabled" {{ 'selected' if u.status == 'disabled' }}>停用</option>
</select>
</td>
<td class="mono sm nowrap">{{ u.last_login_at or '—' }}</td>
<td class="num">{{ u.login_count }}</td>
<td class="num nowrap">{{ '{:,}'.format(u.recs or 0) }} 条
<br><span class="muted sm">{{ '%.2f'|format(u.credits or 0) }} 分</span></td>
<td class="mono sm nowrap">{{ (u.last_login_at or '—')[:16] }}
{% if u.last_login_ip %}<br><span class="muted">{{ u.last_login_ip }}</span>{% endif %}</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 ghost" type="button" data-act="toggle">
{{ '停用' if (u.status or 'active') == 'active' else '启用' }}</button>
<button class="btn sm danger" type="button" data-act="del">删除</button>
{% endif %}
</td>
@@ -75,14 +91,19 @@
</tbody>
</table>
</div>
<p class="hint">「改密」会依次询问新密码与确认;管理员不能取消自己的管理员身份,任何人也不能删除自己。</p>
<p class="hint">
「停用」会让该账号<b>立刻</b>失效(每个请求都会校验状态,不必等会话过期),
其数据与 Cookie 都保留;「删除」是<b>不可逆</b>的,会连同该账号的用量数据与 Cookie
一起删除(接口层另有保留数据的开关,供脚本调用时指定)。
管理员不能取消自己的管理员身份、不能停用或删除自己,也不能删掉最后一个启用的管理员。
</p>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>用户操作审计</h2>
<span class="hint">最近 20 条</span>
<h2>账号操作审计</h2>
<span class="hint">最近 20 条(含注册与登录失败)</span>
</div>
<div class="tablewrap scroll-y">
<table class="tbl">
@@ -139,20 +160,31 @@
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;
var adm = row.querySelector('[name=is_admin]');
if (adm && !adm.disabled) fields.is_admin = adm.value;
var st = row.querySelector('[name=status]');
if (st && !st.disabled) fields.status = st.value;
done(WBU.post('/api/users/' + uid, fields));
} else if (act === 'pwd') {
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 6 位)');
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 8 位,需含两类字符)');
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 + '」?该操作不可撤销(其历史审计记录会保留)。')) {
} else if (act === 'toggle') {
var cur = row.dataset.status === 'active';
var next = cur ? 'disabled' : 'active';
if (!window.confirm(cur ? ('停用「' + name + '」?其数据与 Cookie 会保留,但无法登录。')
: ('启用「' + name + '」?'))) {
btn.disabled = false; btn.textContent = old; return;
}
done(WBU.post('/api/users/' + uid, { status: next }));
} else if (act === 'del') {
var keep = window.confirm('确定删除用户「' + name + '」?\n\n'
+ '点「确定」= 连同其用量数据与 Cookie 一起删除(推荐)\n'
+ '点「取消」= 取消本次删除');
if (!keep) { btn.disabled = false; btn.textContent = old; return; }
done(WBU.post('/api/users/' + uid + '/delete', {}));
} else {
btn.disabled = false; btn.textContent = old;