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
+12 -188
View File
@@ -39,21 +39,26 @@ async function openAdminSettings(refreshOnly = false) {
const payload = await apiRequest("/api/admin/settings");
const data = payload.data || {};
const ifind = data.ifind || {};
const llm = payload.llm || {};
const membership = payload.membership || {};
status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
status.classList.toggle("connected", Boolean(data.configured));
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
renderDatahubRouteStatus(data.datahub || {});
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
renderAdminUsers(payload.users || []);
} catch (error) {
status.textContent = error.message || "系统配置读取失败";
}
}
function dataHubConsoleUrl() {
if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL);
return `${window.location.protocol}//${window.location.hostname}:8766/admin/`;
}
function openDataHubConsole() {
if (state.user?.role !== "admin") return;
window.open(dataHubConsoleUrl(), "_blank", "noopener");
}
function renderDatahubRouteStatus(hub) {
const box = document.querySelector("#datahubRouteStatus");
if (!box) return;
@@ -74,129 +79,6 @@ function renderDatahubRouteStatus(hub) {
}
}
function selectAdminPanel(panel) {
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
document.querySelector("#adminSectionSelect").value = selected;
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
item.hidden = item.dataset.adminPanel !== selected;
});
}
function renderModelPool(models, primaryId = "", fallbackId = "") {
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
const container = document.querySelector("#modelPoolList");
container.innerHTML = state.adminModels.map((item, index) => `
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
<div class="model-pool-fields">
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
</div>
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
</article>
`).join("") || emptyStateHtml("模型池为空,请先添加模型");
updateModelRoleOptions(primaryId, fallbackId);
container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]"))));
container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]"))));
container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels));
refreshIcons();
}
function collectModelPool() {
const saved = new Map(state.adminModels.map((item) => [item.id, item]));
return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({
id: row.dataset.modelId,
name: row.querySelector("[data-model-field='name']").value.trim(),
base_url: row.querySelector("[data-model-field='base_url']").value.trim(),
model: row.querySelector("[data-model-field='model']").value.trim(),
api_key: row.querySelector("[data-model-field='api_key']").value.trim(),
configured: Boolean(saved.get(row.dataset.modelId)?.configured),
}));
}
function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) {
const models = collectModelPool();
const options = models.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
const primary = document.querySelector("#platformPrimaryModelSelect");
const fallback = document.querySelector("#platformFallbackModelSelect");
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
fallback.innerHTML = `<option value="">不启用辅助模型</option>${options}`;
primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || "";
fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : "";
}
function updateModelRoleLabels() {
updateModelRoleOptions();
}
function addPlatformModel() {
const models = collectModelPool();
const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value);
document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus();
}
function deletePlatformModel(row) {
if (!row) return;
const id = row.dataset.modelId;
const primary = document.querySelector("#platformPrimaryModelSelect").value;
const fallback = document.querySelector("#platformFallbackModelSelect").value;
if (id === primary || id === fallback) {
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
return;
}
const models = collectModelPool().filter((item) => item.id !== id);
renderModelPool(models, primary, fallback);
}
function renderAdminUsers(users) {
const container = document.querySelector("#adminUsersList");
container.innerHTML = users.map((user) => {
const admin = user.role === "admin";
const member = Boolean(user.membership_subscribed);
const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
const expiry = member
? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效")
: user.membership_status === "suspended"
? "会员已停用"
: user.membership_status === "active" && user.membership_expires_at
? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期`
: "尚未开通";
return `<article class="admin-user-row" data-admin-user="${number(user.id)}">
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
<form class="membership-form">
<input type="hidden" name="user_id" value="${number(user.id)}">
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
<button class="button" type="submit">应用</button>
</form>
</article>`;
}).join("") || emptyStateHtml("暂无注册用户");
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
}
async function saveMembership(event) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
const button = form.querySelector("button[type='submit']");
button.disabled = true;
try {
const payload = await apiRequest("/api/admin/membership", "POST", data);
renderAdminUsers(payload.users || []);
showToast("会员状态已更新");
} catch (error) {
showToast(error.message || "会员状态保存失败");
} finally {
button.disabled = false;
}
}
async function saveMarketSettings(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
@@ -214,70 +96,12 @@ async function saveMarketSettings(event) {
}
}
async function saveModelPool(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
models: collectModelPool(),
primary_model_id: document.querySelector("#platformPrimaryModelSelect").value,
fallback_model_id: document.querySelector("#platformFallbackModelSelect").value,
});
showToast("模型池已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "模型池保存失败");
} finally {
button.disabled = false;
}
}
async function saveMembershipSettings(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
member_daily_limit: number(document.querySelector("#memberDailyLimit").value),
});
showToast("会员调用额度已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "会员调用额度保存失败");
} finally {
button.disabled = false;
}
}
async function testPlatformModel(row) {
if (!row) return;
const button = row.querySelector("[data-test-model]");
const status = row.querySelector(".model-test-status");
const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {};
button.disabled = true;
status.textContent = "连接中";
try {
const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile });
status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`;
status.className = "model-test-status success";
} catch (error) {
status.textContent = error.message;
status.className = "model-test-status failure";
} finally {
button.disabled = false;
}
}
function bindAdminEvents() {
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
document.querySelector("#settingsButton").addEventListener("click", openDataHubConsole);
document.querySelector("#marketAdminButton").addEventListener("click", () => openAdminSettings());
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool);
document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings);
document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel);
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
}
-271
View File
@@ -125,50 +125,6 @@
width: min(1060px, -24px + 100vw);
}
.admin-section-picker {
display: grid;
grid-template-columns: 96px minmax(220px, 360px);
align-items: center;
gap: 12px;
padding: 16px 18px;
}
.admin-section-picker label {
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
}
.admin-section-picker select {
width: 100%;
min-height: 32px;
padding: 0px 11px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: var(--font-size-label);
}
.admin-section-picker select:focus-visible {
outline: 2px solid var(--action);
outline-offset: 2px;
}
.admin-panel[hidden] {
display: none;
}
@@ -220,161 +176,6 @@
font-weight: 650;
}
.compact-number-field {
width: min(260px, 100%);
margin-top: 14px;
}
.admin-users-list {
display: grid;
border-top: 1px solid var(--line);
}
.admin-user-row {
min-width: 0px;
display: grid;
grid-template-columns: 160px 100px minmax(0px, 1fr);
align-items: center;
gap: 14px;
padding: 13px 0px;
border-bottom: 1px solid var(--line);
}
.admin-user-identity strong {
display: block;
color: var(--text-primary);
font-size: 13px;
}
.admin-user-identity small,
.admin-user-identity span {
display: block;
margin-top: 4px;
color: var(--text-muted);
font-size: 11px;
}
.admin-user-usage {
margin-top: 4px;
color: var(--text-muted);
font-size: 11px;
}
.membership-form {
padding: 0px;
min-width: 0px;
display: grid;
grid-template-columns: 100px minmax(170px, 0.8fr) minmax(160px, 1fr) auto;
align-items: end;
gap: 8px;
}
.membership-form label {
min-width: 0px;
display: grid;
gap: 5px;
}
.membership-form label span {
color: var(--text-muted);
font-size: 10px;
}
.membership-form input,
.membership-form select {
width: 100%;
min-width: 0px;
height: 32px;
padding: 0px 8px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
}
.membership-expiry {
min-width: 0px;
display: grid;
gap: 5px;
align-self: end;
}
.membership-expiry span {
color: var(--text-muted);
font-size: 10px;
}
.membership-expiry strong {
min-height: 34px;
display: flex;
align-items: center;
color: var(--text-secondary);
font-size: 12px;
font-weight: 650;
}
@media (max-width: 820px) {
.admin-user-row {
grid-template-columns: 1fr auto;
}
.admin-user-row .membership-form {
grid-column: 1 / -1;
}
.membership-form {
grid-template-columns: repeat(2, minmax(0px, 1fr));
}
.membership-form .button {
align-self: end;
}
}
@media (max-width: 600px) {
.membership-form {
grid-template-columns: minmax(0px, 1fr);
}
}
.connection-status {
margin: 18px 18px 0px;
@@ -399,7 +200,6 @@
padding: 18px;
}
.settings-dialog .membership-form,
.settings-dialog .settings-section > form,
.settings-dialog.admin-dialog > form {
padding: 0px;
@@ -559,7 +359,6 @@
padding-left: 12px;
}
.settings-dialog .membership-form,
.settings-dialog .settings-section > form,
.settings-dialog.admin-dialog > form {
padding: 0px;
@@ -1173,12 +972,6 @@ button.account-role-badge:focus-visible {
}
}
@media (max-width: 600px) {
.admin-section-picker {
grid-template-columns: minmax(0px, 1fr);
}
}
@media (max-width: 520px) {
.settings-dialog .form-field {
width: 100%;
@@ -1520,32 +1313,6 @@ button.account-role-badge:focus-visible {
font-size: 11px;
}
.admin-dialog .admin-section-picker {
grid-template-columns: auto minmax(220px, 300px);
gap: 14px;
padding: 12px 20px;
border-bottom: 1px solid var(--dialog-line);
background: var(--surface-subtle);
}
.admin-dialog .admin-section-picker label {
font-size: 11px;
}
.admin-dialog .admin-section-picker select {
min-height: 32px;
border-color: var(--border-strong);
border-radius: 8px;
font-size: var(--font-size-label);
}
.admin-dialog .admin-panel {
max-width: 860px;
@@ -1556,24 +1323,6 @@ button.account-role-badge:focus-visible {
border-top: 1px solid var(--dialog-line);
}
.admin-dialog .admin-users-list {
border-color: var(--dialog-line);
}
.admin-dialog .admin-user-row {
grid-template-columns: 150px 90px minmax(0px, 1fr);
gap: 12px;
padding: 12px 0px;
border-color: var(--dialog-line);
}
.admin-dialog .membership-form {
grid-template-columns: 92px minmax(135px, 0.8fr) minmax(140px, 1fr) auto;
}
@media (max-width: 760px) {
.account-settings-dialog .membership-status-grid {
grid-template-columns: repeat(2, minmax(0px, 1fr));
@@ -1582,22 +1331,6 @@ button.account-role-badge:focus-visible {
.account-settings-dialog .account-birth-form {
grid-template-columns: 1fr;
}
.admin-dialog .admin-section-picker {
grid-template-columns: 1fr;
gap: 6px;
}
.admin-dialog .admin-user-row {
grid-template-columns: 1fr auto;
}
.admin-dialog .admin-user-row .membership-form {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0px, 1fr));
}
}
@media (max-width: 460px) {
@@ -1612,10 +1345,6 @@ button.account-role-badge:focus-visible {
.account-settings-dialog .membership-comparison > div {
min-width: 510px;
}
.admin-dialog .admin-user-row .membership-form {
grid-template-columns: 1fr;
}
}
:root[data-theme="dark"] :is(.header-command-group, .account-dropdown) {
-58
View File
@@ -1028,64 +1028,6 @@
vertical-align: -1px;
}
.model {
padding: 12px 14px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer;
}
.model:hover {
background: var(--table-hover);
}
.model .r1 {
display: flex;
align-items: center;
gap: 8px;
}
.model .r1 .nm {
font-weight: 700;
font-size: 13px;
}
.model .r1 .lv {
margin-left: auto;
}
.model .r2 {
font-size: 11.5px;
color: var(--sub);
margin-top: 4px;
line-height: 1.6;
}
.model .r3 {
display: flex;
gap: 10px;
margin-top: 6px;
font-size: 10.5px;
color: var(--faint);
}
.model .r3 .q {
border-bottom: 1px dashed var(--faint);
cursor: help;
}
:root[data-theme="dark"] :is(.card-h, .performance-stage-card header, .rotation-card-head, .auction-card-head-v2, .theme-card-head-v2, .dragon-detail-header, .screener-card-head, .mentor-page-header) {
border-color: var(--line-soft);
-16
View File
@@ -29,22 +29,6 @@
margin-top: 22px;
}
.admin-dialog .model-role-selectors {
gap: 10px;
}
.admin-dialog .model-pool-list {
margin-top: 14px;
}
.admin-dialog .model-row {
border-color: var(--border);
border-radius: 8px;
background: var(--surface-subtle);
}
@keyframes overlay-enter {
0% {
opacity: 0;
+8 -1
View File
@@ -6,6 +6,8 @@ function selectAuthMode(mode) {
const registering = state.authMode === "register";
document.querySelector("#authConfirmField").hidden = !registering;
document.querySelector("#authPasswordConfirm").required = registering;
document.querySelector("#authInviteField").hidden = !registering;
document.querySelector("#authInviteCode").required = registering;
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
document.querySelector("#authError").hidden = true;
@@ -24,7 +26,11 @@ async function submitAuthForm(event) {
const button = document.querySelector("#authSubmitButton");
button.disabled = true;
try {
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
const registering = state.authMode === "register";
const credentials = registering
? { username, password, invite_code: document.querySelector("#authInviteCode").value.trim() }
: { username, password };
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", credentials);
document.querySelector("#authForm").reset();
await applyAuthenticatedSession(session);
} catch (error) {
@@ -42,6 +48,7 @@ async function applyAuthenticatedSession(session) {
const isAdmin = session.user?.role === "admin";
updateAccountIdentityBadges(session.user?.membership || {});
document.querySelector("#settingsButton").hidden = !isAdmin;
document.querySelector("#marketAdminButton").hidden = !isAdmin;
document.querySelector("#syncButton").hidden = !isAdmin;
document.querySelector("#reasonForm").hidden = !isAdmin;
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;