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:
@@ -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]');
|
||||
|
||||
Reference in New Issue
Block a user