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>
This commit is contained in:
施工员
2026-09-16 11:44:09 +08:00
co-authored by multica-agent
parent 3203574b6a
commit 3eaa36a8d5
69 changed files with 3678 additions and 1573 deletions
@@ -0,0 +1,139 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INDEX = (ROOT / "admin" / "index.html").read_text(encoding="utf-8")
STYLES = (ROOT / "admin" / "styles.css").read_text(encoding="utf-8")
APP = (ROOT / "admin" / "app.js").read_text(encoding="utf-8")
HTTPAPP = (ROOT / "datahub" / "httpapp.py").read_text(encoding="utf-8")
class ConsoleShellTests(unittest.TestCase):
"""The console shell must carry the site-session gate, not its own login."""
def test_independent_login_and_password_change_are_gone(self) -> None:
for removed in ("login-form", "change-form", "login-view", "change-view", 'value="hub_admin"'):
self.assertNotIn(removed, INDEX, f"{removed} 属于已废弃的独立账号体系")
self.assertNotIn("/admin/api/login", APP)
self.assertNotIn("/admin/api/change-password", APP)
def test_gate_offers_a_way_back_to_the_review_site(self) -> None:
for element in ("gate-view", "gate-title", "gate-desc", "gate-login", "gate-retry"):
self.assertIn(element, INDEX)
self.assertIn("/admin/api/session", APP)
self.assertIn("login_url", APP)
def test_nav_exposes_the_pages_this_console_now_owns(self) -> None:
for page in ("overview", "sources", "models", "members", "lineage"):
self.assertIn(f'data-nav="{page}"', INDEX)
# 路由白名单必须与导航一致,否则点了导航会回落到总览
routed = re.search(r"return \[([^\]]+)\]\.includes\(h\)", APP)
assert routed is not None
for page in ("overview", "sources", "models", "members", "lineage"):
self.assertIn(f"'{page}'", routed.group(1))
class ThemeTokenTests(unittest.TestCase):
"""Day/night is one token set with two value sets — never stacked overrides."""
def test_both_themes_define_the_same_tokens(self) -> None:
night = _token_block(':root,\n:root[data-theme="night"]')
day = _token_block(':root[data-theme="day"]')
self.assertTrue(night)
self.assertEqual(
sorted(night),
sorted(day),
"日间主题必须覆盖同一组变量名,缺一个就会漏出夜间色",
)
def test_no_hardcoded_colours_escape_the_token_set(self) -> None:
# 颜色只要写死在 JS 或组件样式里,切主题就会有一块保持夜间色。
self.assertEqual([], re.findall(r"#[0-9a-fA-F]{6}", APP))
after_tokens = STYLES.split("/* ---------- ambient background", 1)[1]
self.assertEqual([], re.findall(r"#[0-9a-fA-F]{6}", after_tokens))
def test_no_dark_literal_paint_survives_outside_the_token_blocks(self) -> None:
"""A literal dark rgba() would stay dark in day mode — tokens only.
Accent tints are allowed: they are low-opacity washes of the four
status hues and read correctly on either background.
"""
accent = {(34, 211, 238), (52, 211, 153), (251, 191, 36), (248, 113, 113)}
offenders = []
body = STYLES.split("/* ---------- ambient background", 1)[1]
for line in body.splitlines():
for match in re.finditer(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", line):
rgb = tuple(int(match.group(index)) for index in (1, 2, 3))
if rgb in accent or sum(rgb) >= 250:
continue
offenders.append(line.strip()[:80])
self.assertEqual([], offenders, "深色字面值必须收敛成 token,否则日间主题会漏出夜间底色")
def test_theme_choice_survives_a_reload(self) -> None:
self.assertIn("localStorage.getItem('datahub-theme')", APP)
self.assertIn("localStorage.setItem('datahub-theme'", APP)
self.assertIn('data-theme="night"', INDEX)
def test_svg_colours_go_through_style_so_tokens_apply(self) -> None:
# var() 在 SVG 呈现属性里支持不稳,必须写进 style 才吃得到主题变量。
for attribute in ('fill="var(', 'stroke="var(', 'stop-color="var('):
self.assertNotIn(attribute, APP, f"{attribute} 应改写为 style 声明")
class NarrowScreenTests(unittest.TestCase):
def test_narrow_layout_stacks_inputs_and_buttons(self) -> None:
self.assertIn("@media (max-width: 1100px)", STYLES)
narrow = STYLES.split("@media (max-width: 1100px)", 1)[1]
self.assertIn(".field-row { grid-template-columns: minmax(0, 1fr); }", narrow)
# 凭证行与模型行在窄屏都要竖排,按钮才不会和输入框抢同一行
self.assertIn(".cred-line { flex-direction: column;", narrow)
self.assertIn(".model-row { flex-direction: column;", narrow)
class ConsoleEndpointTests(unittest.TestCase):
def test_every_endpoint_the_page_calls_is_routed(self) -> None:
called = {
path.split("?")[0]
for path in re.findall(r"api\('(/admin/api/[^']+)'", APP)
}
self.assertTrue(called)
for path in called:
if path.startswith("/admin/api/sources/"):
continue
self.assertIn(f'"{path}"', HTTPAPP, f"{path} 前端在调,后端没路由")
def test_write_endpoints_are_reached_with_the_csrf_header(self) -> None:
self.assertIn("X-CSRF-Token", APP)
self.assertIn("check_csrf", HTTPAPP)
def _token_block(selector: str) -> list[str]:
start = STYLES.index(selector)
body = STYLES[start:].split("}", 1)[0]
return re.findall(r"(--[a-z0-9-]+):", body)
if __name__ == "__main__":
unittest.main()
class StylesheetIntegrityTests(unittest.TestCase):
"""HEL-560 改造中曾误把样式表尾部整段截断,抽屉/弹层/toast 全部失样,
页面照样能跑、单测照样绿。这里把"每个仍在用的组件都得有样式"钉死。"""
def test_every_component_the_page_renders_still_has_its_own_rules(self) -> None:
for selector in (
".auth-wrap", ".drawer", ".drawer-mask", ".drawer-hd", ".drawer-tab",
".modal-mask", ".modal-box", ".modal-actions", ".opbtn", ".empty-hint",
".toast", ".row-fail", ".row-off", ".spark-end", ".cred-box", ".model-row",
".vend-manual", ".gate-panel", ".dtable", ".invite-code", ".table-foot",
".pbtn", ".field", ".form-hint",
):
self.assertIn(f"{selector} ", STYLES, f"{selector} 的样式丢了")
def test_stylesheet_braces_stay_balanced(self) -> None:
body = STYLES[STYLES.index("/* ================= index.css"):]
self.assertEqual(body.count("{"), body.count("}"))