Files
xiaobai-review/tools/verify_datahub_console_ui.py
T
施工员andmultica-agent 3eaa36a8d5 feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码
主站
- 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号
  同一事务,并发提交只有一个能成功
- 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据
  中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取
- 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为
  「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」;
  随之清理陈旧 CSS

数据中枢
- 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验
  主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站
- 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取
  /models,失败退回卡内手动录入)、会员管理与邀请码页
- 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量

自测
- 主站 verify_baseline 通过(498 项);数据中枢 235 项通过
- tools/verify_datahub_console.py 端到端跑通两服务真实对话;
  tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 11:44:09 +08:00

297 lines
17 KiB
Python

"""数据中枢控制台浏览器自测:真的打开控制台,点一遍新页面。
跑法:python tools/verify_datahub_console_ui.py [--shots 目录]
覆盖门禁、凭证区、模型池(拉取失败→手动录入)、会员与邀请码、日夜主题、1030 窄屏。
与 verify_datahub_console.py 共用沙箱:临时端口 + 临时库,跑完把仓库状态原样放回。
需要 Playwright 与本地 Chromium。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
import verify_datahub_console as backend
ROOT = Path(__file__).resolve().parents[1]
FAILURES: list[str] = []
def chrome_path() -> str | None:
"""Playwright 默认渠道是 msedge;这里优先用它自带的 chromium。
CHROMIUM_PATH 可显式指定;否则在 Playwright 缓存里找一份(版本号会随
Playwright 升级变化,所以按目录名匹配而不写死)。缺 GTK 系 so 时,用
LD_LIBRARY_PATH 指向本地补齐的库目录再跑本脚本。
"""
explicit = os.environ.get("CHROMIUM_PATH")
if explicit:
return explicit
cache = Path.home() / ".cache/ms-playwright"
builds = sorted(cache.glob("chromium-*/chrome-linux*/chrome"), reverse=True)
return str(builds[0]) if builds else None
def check(label: str, ok: bool, detail: str = "") -> None:
print(f" {'PASS' if ok else 'FAIL'} {label}{(' — ' + detail) if detail else ''}")
if not ok:
FAILURES.append(label)
def main() -> int:
shots = Path(sys.argv[sys.argv.index("--shots") + 1]) if "--shots" in sys.argv else None
if shots:
shots.mkdir(parents=True, exist_ok=True)
workdir = Path(backend.tempfile.mkdtemp(prefix="hel560-ui-"))
review_port, hub_port = backend.free_port(), backend.free_port()
review, hub = f"http://127.0.0.1:{review_port}", f"http://127.0.0.1:{hub_port}"
with backend.RepoSandbox():
backend.start_review(workdir, review_port)
backend.wait_for(f"{review}/api/session")
backend.start_hub(workdir, hub_port, review_port)
backend.wait_for(f"{hub}/livez")
status, _, cookie_header = backend.request(
f"{review}/api/auth/register", {"username": "boss", "password": backend.PASSWORD}, "POST"
)
admin_cookie = backend.session_cookie(cookie_header).split("=", 1)[1]
with sync_playwright() as play:
browser = play.chromium.launch(executable_path=chrome_path())
errors: list[str] = []
def new_page(width: int, logged_in: bool):
context = browser.new_context(viewport={"width": width, "height": 900})
if logged_in:
context.add_cookies([
{"name": "xiaobai_session", "value": admin_cookie, "domain": "127.0.0.1", "path": "/"}
])
page = context.new_page()
page.on("pageerror", lambda exc: errors.append(f"{width}px pageerror: {exc}"))
page.on("console", lambda msg: errors.append(f"{width}px console.{msg.type}: {msg.text}")
if msg.type == "error" else None)
page.on("response", lambda res: errors.append(f"{width}px HTTP {res.status} {res.url}")
if res.status >= 400 else None)
return context, page
print("\n[UI-1] 未登录时只看到门禁,不再有独立登录表单")
context, page = new_page(1440, logged_in=False)
page.goto(f"{hub}/admin/", wait_until="networkidle")
check("门禁面板可见", page.is_visible("#gate-view"))
check("控制台外壳隐藏", page.is_hidden("#appRoot"))
check("提示去主站登录", "登录" in page.inner_text("#gate-desc"), page.inner_text("#gate-desc")[:40])
check("给出主站登录链接", "8765" in (page.get_attribute("#gate-login", "href") or "") or
str(review_port) in (page.get_attribute("#gate-login", "href") or ""))
check("页面里没有独立账号输入框", page.locator("#login-form, #change-form").count() == 0)
if shots:
page.screenshot(path=str(shots / "ui-gate.png"), full_page=True)
context.close()
print("\n[UI-2] 带主站管理员会话直接进控制台")
context, page = new_page(1440, logged_in=True)
page.goto(f"{hub}/admin/", wait_until="networkidle")
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
check("控制台外壳渲染", page.is_visible("#appRoot"))
check("右上角显示主站用户名", page.inner_text("#who").strip() == "boss", page.inner_text("#who"))
check("导航含模型池与会员管理", page.locator('[data-nav="models"]').count() == 1
and page.locator('[data-nav="members"]').count() == 1)
print("\n[UI-3] 数据源页带可编辑凭证区")
page.click('[data-nav="sources"]')
page.wait_for_selector('[data-cred-form="tushare"]', timeout=10000)
check("Tushare 卡出现凭证输入框", page.locator('[data-cred-input="tushare_token"]').count() == 1)
check("iFinD 卡也能在线填凭证", page.locator('[data-cred-input="ifind_refresh_token"]').count() == 1)
check("凭证输入是密码框(不回显明文)",
page.get_attribute('[data-cred-input="tushare_token"]', "type") == "password")
check("原有接口清单没被删掉", page.locator("table.dtable").count() >= 1)
if shots:
page.screenshot(path=str(shots / "ui-sources-night.png"), full_page=True)
print("\n[UI-4] 模型池按供应商组织,可拉取/手填")
page.click('[data-nav="models"]')
page.wait_for_selector("#addVendor", timeout=10000)
check("有新增供应商入口", page.is_visible("#addVendor"))
check("有调用编排(主/辅模型)", page.locator("#primaryModel").count() == 1 and page.locator("#fallbackModel").count() == 1)
check("顶部条给出供应商与模型数", "供应商" in page.inner_text(".strip") and "主模型" in page.inner_text(".strip"))
page.select_option("#newVendorPreset", "https://api.openai.com/v1")
page.click("#addVendor")
page.wait_for_selector('[data-vend-fetch="https://api.openai.com/v1"]', timeout=10000)
check("供应商卡有获取模型列表按钮", page.is_visible('[data-vend-fetch="https://api.openai.com/v1"]'))
check("供应商卡有 BASE URL 与 API KEY 两个字段",
page.locator('[data-vend-url="https://api.openai.com/v1"]').count() == 1
and page.locator('[data-vend-key="https://api.openai.com/v1"]').count() == 1)
check("供应商卡可单独保存", page.is_visible('[data-vend-save="https://api.openai.com/v1"]'))
print("\n[UI-4b] 拉取失败后退回卡内手动录入(不弹系统对话框)")
page.fill('[data-vend-key="https://api.openai.com/v1"]', "sk-invalid-for-smoke")
with page.expect_response(lambda res: "/models/fetch" in res.url, timeout=20000):
page.click('[data-vend-fetch="https://api.openai.com/v1"]')
page.wait_for_selector('[data-vend-manual-input="https://api.openai.com/v1"]', timeout=15000)
check("拉取失败给出失败提示", "拉取失败" in page.inner_text(".vend-note.bad"),
page.inner_text(".vend-note.bad")[:80])
check("失败后出现手动录入输入框", page.is_visible('[data-vend-manual-input="https://api.openai.com/v1"]'))
page.fill('[data-vend-manual-input="https://api.openai.com/v1"]', "gpt-4o")
with page.expect_response(lambda res: "/models/save" in res.url, timeout=20000) as saved:
page.click('[data-vend-manual-add="https://api.openai.com/v1"]')
check("手动录入的模型保存成功", saved.value.status == 200, str(saved.value.status))
page.wait_for_selector(".model-row", timeout=15000)
row = page.inner_text(".model-row")
check("模型行显示名称/供应商/测试与删除", "gpt-4o" in row and "OpenAI" in row
and page.locator("[data-model-test]").count() >= 1
and page.locator("[data-model-remove]").count() >= 1, row.replace("\n", " | ")[:100])
check("首个模型自动成为主模型", "主模型" in row, row.replace("\n", " | ")[:80])
if shots:
page.screenshot(path=str(shots / "ui-models-night.png"), full_page=True)
print("\n[UI-5] 会员管理 + 邀请码:生成、复制、作废")
page.click('[data-nav="members"]')
page.wait_for_selector("#createInvites", timeout=10000)
check("会员表渲染出主站账号", "boss" in page.inner_text("table.dtable"))
check("有会员额度输入与保存", page.locator("#memberQuota").count() == 1 and page.locator("#saveQuota").count() == 1)
page.click("#createInvites")
page.wait_for_selector("[data-invite-copy]", timeout=10000)
code_text = page.inner_text("td.invite-code")
check("生成后当次显示完整邀请码", code_text.count("-") >= 3 and "•" not in code_text, code_text)
check("未使用的码可复制可作废",
page.locator("[data-invite-copy]").count() >= 1 and page.locator("[data-invite-revoke]").count() >= 1)
page.once("dialog", lambda dialog: dialog.accept())
revoke_response = None
with page.expect_response(lambda res: "/invites/revoke" in res.url, timeout=10000) as caught:
page.click("[data-invite-revoke]")
revoke_response = caught.value
page.wait_for_timeout(600)
check("作废接口返回 200", revoke_response.status == 200,
f"{revoke_response.status} {revoke_response.text()[:120]}")
invite_row = page.inner_text("tr:has(td.invite-code)")
check("作废后该行状态变为作废", "作废" in invite_row, invite_row.replace("\n", " | ")[:120])
check("作废后不再显示完整码,只留掩码", "•" in page.inner_text("td.invite-code"),
page.inner_text("td.invite-code"))
check("作废后复制与作废按钮都收起",
page.locator("[data-invite-copy]").count() == 0 and page.locator("[data-invite-revoke]").count() == 0)
if shots:
page.screenshot(path=str(shots / "ui-members-night.png"), full_page=True)
print("\n[UI-6] 日间 / 夜间主题切换")
check("默认夜间", page.get_attribute("html", "data-theme") == "night")
page.click("#themeBtn")
page.wait_for_timeout(300)
check("切到日间后 data-theme=day", page.get_attribute("html", "data-theme") == "day")
body_bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
check("日间底色是浅色", _is_light(body_bg), body_bg)
check("按钮文案回切为夜间", page.inner_text("#themeBtn").strip() == "夜间", page.inner_text("#themeBtn"))
if shots:
page.screenshot(path=str(shots / "ui-members-day.png"), full_page=True)
page.click('[data-nav="sources"]')
page.wait_for_timeout(600)
page.screenshot(path=str(shots / "ui-sources-day.png"), full_page=True)
page.click('[data-nav="models"]')
page.wait_for_timeout(600)
page.screenshot(path=str(shots / "ui-models-day.png"), full_page=True)
page.click("#themeBtn")
page.reload(wait_until="networkidle")
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
check("主题选择刷新后保持", page.get_attribute("html", "data-theme") == "night")
context.close()
print("\n[UI-7] 窄屏 1030px:输入框与按钮竖排不重叠")
context, page = new_page(1030, logged_in=True)
page.goto(f"{hub}/admin/#sources", wait_until="networkidle")
page.wait_for_selector('[data-cred-form="tushare"]', timeout=15000)
# 窄屏下"输入框一行、按钮整排落到下一行"是样图 1030 的硬要求
stacked = page.evaluate(
"""() => {
const bad = [];
document.querySelectorAll('.cred-box').forEach((box) => {
const input = box.querySelector('input');
const button = box.querySelector('.pbtn');
if (!input || !button) return;
const a = input.getBoundingClientRect();
const b = button.getBoundingClientRect();
const overlap = a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom;
if (overlap) bad.push(box.dataset.credForm + ':重叠');
if (b.top < a.bottom - 1) bad.push(box.dataset.credForm + ':同行');
});
return bad;
}"""
)
check("凭证输入框与按钮竖排不重叠", stacked == [], str(stacked))
clipped = page.evaluate(
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn')]"
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
)
check("窄屏没有控件溢出视口", clipped == 0, str(clipped))
if shots:
page.screenshot(path=str(shots / "ui-sources-1030.png"), full_page=True)
page.goto(f"{hub}/admin/#models", wait_until="networkidle")
page.wait_for_selector(".vend-line", timeout=15000)
# 供应商卡的地址/Key/按钮在宽屏并排,窄屏必须整列竖排
model_stacked = page.evaluate(
"""() => {
const line = document.querySelector('.vend-line');
const kids = [...line.children];
const bad = [];
for (let i = 1; i < kids.length; i += 1) {
const prev = kids[i - 1].getBoundingClientRect();
const cur = kids[i].getBoundingClientRect();
if (cur.top < prev.bottom - 1) bad.push(i);
}
return bad;
}"""
)
check("供应商卡地址/Key/按钮窄屏竖排", model_stacked == [], str(model_stacked))
model_clipped = page.evaluate(
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn, .model-row')]"
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
)
check("模型页窄屏没有控件溢出视口", model_clipped == 0, str(model_clipped))
if shots:
page.screenshot(path=str(shots / "ui-models-1030.png"), full_page=True)
page.goto(f"{hub}/admin/#members", wait_until="networkidle")
page.wait_for_timeout(800)
page.screenshot(path=str(shots / "ui-members-1030.png"), full_page=True)
context.close()
print("\n[UI-8] 全程无 JS 异常与服务端错误")
# 预期噪音:未登录时门禁本来就会拿到 401;favicon 本项目没提供。
def expected(line: str) -> bool:
if "favicon" in line:
return True
if "401" in line and "/admin/api/session" in line:
return True
if "400" in line and "/admin/api/models/fetch" in line:
return True # UI-4b 故意用错 Key 拉取,400 是本轮要验的正确行为
if "console.error: Failed to load resource" in line:
return True # 上面两类的浏览器侧复述,URL 已单独判过
return False
crashes = [line for line in errors if "pageerror" in line]
server_errors = [line for line in errors if "HTTP 5" in line]
unexpected = [line for line in errors if not expected(line) and "pageerror" not in line
and "HTTP 5" not in line]
check("没有 JS 未捕获异常", crashes == [], "; ".join(crashes[:3]))
check("没有 5xx 服务端错误", server_errors == [], "; ".join(server_errors[:3]))
check("没有其它意外失败请求", unexpected == [], "; ".join(unexpected[:3]))
browser.close()
backend.shutil.rmtree(workdir, ignore_errors=True)
print("\n" + "=" * 60)
if FAILURES:
print(f"FAILED {len(FAILURES)} 项:")
for item in FAILURES:
print(" - " + item)
return 1
print("数据中枢控制台浏览器自测全部通过")
return 0
def _is_light(colour: str) -> bool:
numbers = [int(part) for part in colour.replace("rgba", "").replace("rgb", "").strip("() ").split(",")[:3]]
return sum(numbers) / 3 > 160
if __name__ == "__main__":
raise SystemExit(main())