feat(HEL-566): 去掉主站行情管理入口并修好模型列表收起

主站行情管理并入数据中枢
- 数据中枢数据源配置页新增「主站行情任务」卡:交易时段后台刷新开关、
  手动后台刷新、历史区间回补(任务提交 + 轮询主站 job 记录亮灯),
  数据与调度仍归主站,控制台只经桥接代操作
- 新增桥接端点 /api/hub-admin/market/{refresh,backfill};回补改为
  market.backfill 后台任务(jobs.config.json 注册,锁独立,超时 30 分钟),
  桥接调用秒回,不再阻塞
- 主站桌面端删除顶栏「行情管理」按钮与整个行情管理对话框,清理随之
  失效的 CSS;移动端删除 system/admin 页与入口,管理员专区只留数据中枢
- Tushare Token / iFinD 凭证编辑沿用数据源页既有凭证区,无功能缺失

模型池收起修复
- 拉出模型清单后按钮切换为「收起列表」,收起只留一行摘要;再次点击
  重新拉取并展开;勾选添加完成后清单自动收起(原逻辑保留)
- 交互全部沿用 HEL-558 已确认样式的既有按钮与提示组件,未新增视觉

自测
- verify_baseline 通过;pytest 492 项通过;数据中枢 240 项通过
- verify_datahub_console 新增 [11b] 行情任务桥接端到端段;UI 自测新增
  行情任务卡开关往返、模型清单展开/收起/再展开/添加自动收起,日夜主题
  与 1030 窄屏复验通过
- Playwright e2e 102/103:唯一失败项在基线提交上同样失败(本机字体度量
  导致的头部溢出,与本卡无关)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-16 17:24:05 +08:00
co-authored by multica-agent
parent 44d6de3e2b
commit fa82b8621e
28 changed files with 563 additions and 551 deletions
+185 -4
View File
@@ -20,6 +20,8 @@ const state = {
// 站点侧数据(模型池 / 会员 / 邀请码)由主站经桥接返回,只在本页缓存
models: null, members: null, invites: null,
vendorDraft: {}, freshCodes: {}, modelTests: {},
// HEL-566: 主站行情任务(后台刷新开关 / 手动刷新 / 历史回补)同样经桥接
market: { status: null, toggleBusy: false, jobs: { refresh: null, backfill: null } },
};
async function api(path, options = {}) {
@@ -735,6 +737,7 @@ function sourcesHtml() {
`<span class="flex1"></span>` +
`<span style="font-size:10px;color:var(--dim);letter-spacing:.12em">凭证安全存储 · 探测只读 · 不影响盘中链路</span>` +
`</div>` +
`<div id="marketCard" class="col">${marketCardHtml()}</div>` +
`<div id="srcCards" class="col">${vm.cards.map(sourceCardHtml).join('')}</div>` +
`<div class="rsv-grid">${vm.reserved.map((r) => `<div class="rsv">${ledHtml('off')}<span style="font-size:13px">${esc(r.label)}</span>${tagHtml('dashed', '预留位 · 未接入')}</div>`).join('')}</div>` +
`</div>`;
@@ -746,6 +749,148 @@ function renderSourceCards() {
paintSparks();
}
/* =====================================================================
主站行情任务(HEL-566:自主站「行情管理」迁入,数据与调度仍归主站)
===================================================================== */
function dashDate(compact) {
const s = String(compact || '');
return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s;
}
async function loadMarketStatus() {
try {
state.market.status = await api('/admin/api/market/status');
} catch (err) {
state.market.status = { error: err.message };
}
renderMarketCard();
}
function marketJobNote(job) {
if (!job) return '';
if (job.status === 'running') return `${ledHtml('rev', true)}任务运行中…`;
if (job.status === 'success') return `${ledHtml('ok')}任务完成${job.finished_at ? ' · ' + esc(hm(job.finished_at)) : ''}`;
return `${ledHtml('fail')}任务失败${job.message || job.error_code ? ' · ' + esc(plainError(String(job.message || job.error_code || ''), 24)) : ''}`;
}
function marketCardHtml() {
const data = state.market.status;
let body;
if (!data) body = '<p class="form-hint">正在读取主站行情设置…</p>';
else if (data.error) {
body = `<div class="vend-note bad">${ledHtml('fail')}主站行情状态读取失败 · ${esc(data.error)}</div>`;
} else {
const enabled = Boolean(data.background_refresh_enabled);
body =
`<div class="route-line">` +
`<div class="field"><span class="lab">交易时段后台刷新</span>` +
`<span class="vend-note">${ledHtml(enabled ? 'ok' : 'off')}${enabled ? '已启用 · 后台定时更新公共快照' : '已暂停 · 交易时段不再自动刷新'}</span></div>` +
`<span class="form-actions">` +
`<button class="tbtn" data-market-toggle${state.market.toggleBusy ? ' disabled' : ''}>${state.market.toggleBusy ? '保存中…' : (enabled ? '暂停后台刷新' : '启用后台刷新')}</button>` +
`</span></div>` +
`<div class="route-line">` +
`<div class="field"><span class="lab">手动刷新快照日期</span><input type="date" data-market-refresh-date value="${esc(dashDate(today()))}"></div>` +
`<span class="form-actions"><button class="pbtn" data-market-refresh>立即后台刷新</button>` +
`<span class="vend-note">${marketJobNote(state.market.jobs.refresh)}</span></span></div>` +
`<div class="form-hint">所有用户共读同一份主站快照,任务在主站后台执行,完成后此处亮绿灯。</div>` +
`<div class="route-line">` +
`<div class="field"><span class="lab">历史回补开始日期</span><input type="date" data-market-backfill-start></div>` +
`<div class="field"><span class="lab">历史回补结束日期</span><input type="date" data-market-backfill-end></div>` +
`<span class="form-actions"><button class="tbtn" data-market-backfill>开始回补</button>` +
`<span class="vend-note">${marketJobNote(state.market.jobs.backfill)}</span></span></div>` +
`<div class="form-hint">补齐缺失的历史行情快照,区间最多 15 个交易日,周末与节假日自动跳过。</div>`;
}
return panelHtml('主站行情任务', tagHtml('dim', '自主站行情管理迁入'), body);
}
function renderMarketCard() {
const el = $('marketCard');
if (!el) return;
el.innerHTML = marketCardHtml();
}
function trackMarketJob(kind, jobKey) {
state.market.jobs[kind] = { status: 'running' };
renderMarketCard();
let attempts = 0;
// 刷新最快数十秒;回补按分钟计。超时只是停止等待,任务仍在主站继续。
const limit = kind === 'backfill' ? 450 : 120;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > limit) {
clearInterval(timer);
state.market.jobs[kind] = { status: 'running', message: '等待超时,任务仍在主站执行,可稍后重进本页查看结果' };
renderMarketCard();
return;
}
try {
const status = await api('/admin/api/market/status');
state.market.status = status;
const job = (status.jobs || []).find((item) => item.idempotency_key === jobKey);
if (job && (job.status === 'success' || job.status === 'failed')) {
clearInterval(timer);
state.market.jobs[kind] = job;
renderMarketCard();
toast(
job.status === 'success' ? (kind === 'backfill' ? '历史回补完成' : '后台刷新完成') : `任务失败:${job.message || job.error_code || '未知错误'}`,
job.status === 'success' ? 'ok' : 'err'
);
return;
}
if (job) state.market.jobs[kind] = job;
} catch {}
}, 2000);
}
async function toggleMarketRefresh() {
const data = state.market.status;
if (!data || data.error || state.market.toggleBusy) return;
state.market.toggleBusy = true;
renderMarketCard();
try {
state.market.status = await api('/admin/api/market/settings', {
method: 'POST',
body: JSON.stringify({ background_refresh_enabled: !data.background_refresh_enabled }),
});
toast(!data.background_refresh_enabled ? '后台刷新已启用' : '后台刷新已暂停', 'ok');
} catch (err) {
toast(err.message, 'err');
}
state.market.toggleBusy = false;
renderMarketCard();
}
async function startMarketRefresh() {
const dateEl = document.querySelector('[data-market-refresh-date]');
try {
const result = await api('/admin/api/market/refresh', {
method: 'POST',
body: JSON.stringify({ trade_date: dateEl ? dateEl.value : '' }),
});
if (!result.started) { toast(result.message || '已有后台刷新任务正在运行', 'err'); return; }
toast(result.message || '后台刷新已开始', 'ok');
trackMarketJob('refresh', result.job_key);
} catch (err) { toast(err.message, 'err'); }
}
async function startMarketBackfill() {
const startEl = document.querySelector('[data-market-backfill-start]');
const endEl = document.querySelector('[data-market-backfill-end]');
const start = startEl ? startEl.value : '';
const end = endEl ? endEl.value : '';
if (!start || !end) { toast('请先选择回补的开始与结束日期', 'err'); return; }
if (!window.confirm(`确定回补 ${start}${end} 的历史行情?`)) return;
try {
const result = await api('/admin/api/market/backfill', {
method: 'POST',
body: JSON.stringify({ start_date: start, end_date: end }),
});
if (!result.started) { toast(result.message || '已有回补任务正在运行', 'err'); return; }
toast(result.message || '回补任务已开始', 'ok');
trackMarketJob('backfill', result.job_key);
} catch (err) { toast(err.message, 'err'); }
}
/* =====================================================================
数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲)
===================================================================== */
@@ -1002,7 +1147,7 @@ function lineageHtml() {
===================================================================== */
function vendorDraft(key) {
if (!state.vendorDraft[key]) {
state.vendorDraft[key] = { fetching: false, fetched: null, fetchedAt: '', error: '', manual: false, url: '', key: '' };
state.vendorDraft[key] = { fetching: false, fetched: null, fetchedAt: '', error: '', manual: false, url: '', key: '', collapsed: false };
}
return state.vendorDraft[key];
}
@@ -1045,6 +1190,12 @@ function vendorFetchHtml(group, draft) {
manualEntryHtml(group);
}
if (!draft.fetched) return group.models.length ? '' : manualEntryHtml(group);
// HEL-566: 拉取结果可收起。收起后只留一行摘要,重新拉取即可再次展开。
if (draft.collapsed) {
return `<div class="vend-note">${ledHtml('ok')}已拉取 ${draft.fetched.length} 个模型` +
`${draft.fetchedAt ? ' · ' + esc(draft.fetchedAt) : ''} · 清单已收起</div>` +
manualEntryHtml(group);
}
const existing = new Set(group.models.map((model) => String(model.model || '')));
const pending = draft.fetched.filter((name) => !existing.has(name));
const picks = draft.fetched.map((name) => {
@@ -1086,7 +1237,7 @@ function vendorCardHtml(group) {
`<span class="form-hint">${group.configured ? '掩码显示 · 留空保持不变' : '密钥仅服务端加密保存,任何界面不回显明文'}</span></div>` +
`<span class="vend-acts">` +
`<button class="tbtn" data-vend-fetch="${esc(group.base_url)}"${draft.fetching ? ' disabled' : ''}>` +
`${group.models.length ? '重新拉取模型列表' : '获取模型列表'}</button>` +
`${vendFetchLabel(group, draft)}</button>` +
`<button class="pbtn" data-vend-save="${esc(group.base_url)}">保存供应商</button></span>` +
`</div>` +
vendorFetchHtml(group, draft) +
@@ -1098,6 +1249,13 @@ function vendorCardHtml(group) {
`<div class="panel-b vend-body">${body}</div></section>`;
}
/* HEL-566: 列表展开时按钮变「收起列表」,收起/未拉取时回到拉取文案,
点击重新拉取会再次展开,交互沿用既有按钮,不新增视觉元素。 */
function vendFetchLabel(group, draft) {
if (draft.fetched && !draft.collapsed && !draft.fetching) return '收起列表';
return group.models.length ? '重新拉取模型列表' : '获取模型列表';
}
function modelsHtml() {
const data = state.models;
if (!data) return panelHtml('模型池', '', '<p class="form-hint">正在从主站读取模型池…</p>');
@@ -1267,7 +1425,11 @@ function renderPage(p) {
mainEl.dataset.page = p;
document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p));
if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); }
else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); }
else if (p === 'sources') {
mainEl.innerHTML = sourcesHtml();
paintSparks();
if (!state.market.status) loadMarketStatus();
}
else if (p === 'models') {
mainEl.innerHTML = modelsHtml();
restoreVendorDrafts();
@@ -1325,7 +1487,7 @@ function updatePhaseTag() {
/* ---------- 模型池 / 会员页的事件(沿用 mainEl 的委托,不另加监听) ---------- */
function handleModelClick(e) {
const fetchBtn = e.target.closest('[data-vend-fetch]');
if (fetchBtn) { fetchVendorModels(fetchBtn.dataset.vendFetch); return true; }
if (fetchBtn) { toggleVendorList(fetchBtn.dataset.vendFetch); return true; }
const saveVendor = e.target.closest('[data-vend-save]');
if (saveVendor) { saveVendorProfile(saveVendor.dataset.vendSave); return true; }
const manual = e.target.closest('[data-vend-manual]');
@@ -1373,6 +1535,7 @@ async function fetchVendorModels(baseUrl) {
if (!key && !vendorGroup(baseUrl).configured) { toast('请先填写 API Key,再拉取模型列表', 'err'); return; }
draft.fetching = true;
draft.error = '';
draft.collapsed = false;
renderPage('models');
try {
const result = await api('/admin/api/models/fetch', { method: 'POST', body: JSON.stringify({ base_url: url, api_key: key }) });
@@ -1387,6 +1550,17 @@ async function fetchVendorModels(baseUrl) {
renderPage('models');
}
/* HEL-566: 拉出的清单可收起可展开——展开时收起,收起/未拉取时重新拉取。 */
function toggleVendorList(baseUrl) {
const draft = vendorDraft(baseUrl);
if (!draft.fetching && draft.fetched && !draft.collapsed) {
draft.collapsed = true;
renderPage('models');
return;
}
fetchVendorModels(baseUrl);
}
function nextModelId(model) {
// 主站要求模型 ID 匹配 [A-Za-z0-9_-]{3,80},这里把模型名压成合法且唯一的 ID。
const used = new Set(state.models.models.map((m) => m.id));
@@ -1444,6 +1618,7 @@ async function addModels(baseUrl, names) {
if (!added.length) { toast('这些模型已在池中', 'err'); return; }
const draft = vendorDraft(baseUrl);
draft.fetched = null;
draft.collapsed = false;
draft.manual = false;
draft.key = '';
const extra = {};
@@ -1592,6 +1767,12 @@ async function revokeInvite(codeId) {
}
mainEl.addEventListener('click', (e) => {
const mt = e.target.closest('[data-market-toggle]');
if (mt) { toggleMarketRefresh(); return; }
const mr = e.target.closest('[data-market-refresh]');
if (mr) { startMarketRefresh(); return; }
const mb = e.target.closest('[data-market-backfill]');
if (mb) { startMarketBackfill(); return; }
const pb = e.target.closest('[data-probe]');
if (pb) { doProbe(pb.dataset.probe); return; }
const tg = e.target.closest('[data-toggle]');
+12
View File
@@ -238,6 +238,18 @@ class HubRequestHandler(BaseHTTPRequestHandler):
self._json(console.test_model(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/models/fetch" and method == "POST":
self._json(console.fetch_models(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/market/status" and method == "GET":
self._json(console.market_status(), HTTPStatus.OK)
elif path == "/admin/api/market/settings" and method == "POST":
self._json(console.save_market_settings(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/market/refresh" and method == "POST":
payload = console.market_refresh(self._read_json(allow_empty=True))
self.hub.pipeline.audit(user["username"], "market_refresh", str(payload.get("job_key") or ""), "")
self._json(payload, HTTPStatus.OK)
elif path == "/admin/api/market/backfill" and method == "POST":
payload = console.market_backfill(self._read_json())
self.hub.pipeline.audit(user["username"], "market_backfill", str(payload.get("job_key") or ""), "")
self._json(payload, HTTPStatus.OK)
elif path == "/admin/api/members" and method == "GET":
self._json(console.members(), HTTPStatus.OK)
elif path == "/admin/api/members/save" and method == "POST":
+48
View File
@@ -79,6 +79,54 @@ class SiteConsole:
)
return {"models": payload.get("models") or []}
# ---------------------------------------------------------------- market
# HEL-566: 行情管理整体并入数据中枢。后台刷新开关、手动刷新与历史回补
# 仍由主站执行(数据与调度都归主站),控制台只是经桥接代为操作,
# 任务状态也从主站 /api/hub-admin/status 的 jobs 里读。
def market_status(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/status")
data = payload.get("data") or {}
return {
"background_refresh_enabled": bool(data.get("background_refresh_enabled")),
"configured": bool(data.get("configured")),
"snapshot_dates": data.get("snapshot_dates") or 0,
"jobs": data.get("jobs") or [],
}
def save_market_settings(self, body: dict[str, Any]) -> dict[str, Any]:
if "background_refresh_enabled" not in body:
raise ApiError("INVALID_ARGUMENT", "没有需要保存的行情设置")
self._call(
"/api/hub-admin/settings/save",
{"background_refresh_enabled": bool(body.get("background_refresh_enabled"))},
)
return self.market_status()
def market_refresh(self, body: dict[str, Any]) -> dict[str, Any]:
payload = self._call(
"/api/hub-admin/market/refresh",
{"trade_date": str(body.get("trade_date") or "")},
)
return {
"started": bool(payload.get("started")),
"job_key": str(payload.get("job_key") or ""),
"message": str(payload.get("message") or ""),
}
def market_backfill(self, body: dict[str, Any]) -> dict[str, Any]:
payload = self._call(
"/api/hub-admin/market/backfill",
{
"start_date": str(body.get("start_date") or ""),
"end_date": str(body.get("end_date") or ""),
},
)
return {
"started": bool(payload.get("started")),
"job_key": str(payload.get("job_key") or ""),
"message": str(payload.get("message") or ""),
}
# --------------------------------------------------------------- members
def members(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/members")
@@ -122,6 +122,60 @@ class MemberAndQuotaTests(unittest.TestCase):
self.assertEqual([], self.bridge.paths())
class MarketTaskTests(unittest.TestCase):
"""HEL-566: 主站行情管理并入中枢后的桥接映射。"""
def setUp(self) -> None:
self.bridge = RecordingBridge(
{
"/api/hub-admin/status": {
"data": {
"background_refresh_enabled": True,
"configured": True,
"snapshot_dates": 12,
"jobs": [{"idempotency_key": "manual:20260916:1", "status": "success"}],
}
},
"/api/hub-admin/settings/save": {},
"/api/hub-admin/market/refresh": {"started": True, "job_key": "manual:20260916:1", "message": "后台刷新已开始"},
"/api/hub-admin/market/backfill": {"started": True, "job_key": "backfill:20260901:20260908:1", "message": "历史回补任务已开始"},
}
)
self.console = SiteConsole(self.bridge)
def test_market_status_projects_only_what_the_card_needs(self) -> None:
payload = self.console.market_status()
self.assertTrue(payload["background_refresh_enabled"])
self.assertEqual(1, len(payload["jobs"]))
self.assertNotIn("llm", payload)
self.assertNotIn("users", payload)
def test_toggling_background_refresh_saves_then_reads_back(self) -> None:
payload = self.console.save_market_settings({"background_refresh_enabled": False})
self.assertEqual(
["/api/hub-admin/settings/save", "/api/hub-admin/status"],
self.bridge.paths(),
)
self.assertEqual({"background_refresh_enabled": False}, self.bridge.calls[0][1])
self.assertTrue(payload["background_refresh_enabled"]) # 读回的是主站真实状态
def test_saving_without_the_toggle_is_refused(self) -> None:
with self.assertRaises(ApiError):
self.console.save_market_settings({})
self.assertEqual([], self.bridge.paths())
def test_refresh_and_backfill_forward_their_payloads(self) -> None:
refresh = self.console.market_refresh({"trade_date": "2026-09-16"})
self.assertTrue(refresh["started"])
self.assertEqual({"trade_date": "2026-09-16"}, self.bridge.calls[0][1])
backfill = self.console.market_backfill({"start_date": "2026-09-01", "end_date": "2026-09-08"})
self.assertEqual("backfill:20260901:20260908:1", backfill["job_key"])
self.assertEqual(
{"start_date": "2026-09-01", "end_date": "2026-09-08"},
self.bridge.calls[1][1],
)
class InviteTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = RecordingBridge(