From 3304f00ccdc4affc16fee45bd041c80df86ccdae Mon Sep 17 00:00:00 2001 From: X-PG13 <2720174336@qq.com> Date: Sat, 30 May 2026 08:44:26 +0800 Subject: [PATCH] ui: add admin console empty states --- src/ainews/web/app.js | 149 +++++++++++++++++++++++++++++--------- src/ainews/web/index.html | 5 ++ src/ainews/web/styles.css | 90 ++++++++++++++++++++++- tests/test_web_console.py | 36 +++++++++ 4 files changed, 246 insertions(+), 34 deletions(-) diff --git a/src/ainews/web/app.js b/src/ainews/web/app.js index 7613d06..18e5a4b 100644 --- a/src/ainews/web/app.js +++ b/src/ainews/web/app.js @@ -92,6 +92,20 @@ function logJob(title, payload) { refs.jobOutput.textContent = `${title}\n${JSON.stringify(payload, null, 2)}`; } +function emptyState(title, body, steps = []) { + const renderedSteps = steps.length + ? `
    ${steps.map((step) => `
  1. ${escapeHtml(step)}
  2. `).join("")}
` + : ""; + return ` +
+

Next step

+

${escapeHtml(title)}

+

${escapeHtml(body)}

+ ${renderedSteps} +
+ `; +} + function getFilters() { return { region: refs.regionSelect.value, @@ -251,7 +265,10 @@ function renderOperationsPipelineRuns(runs) { ` ) .join("") - : '

最近还没有记录到 pipeline 运行。

'; + : emptyState("先跑一次流水线", "这里会记录 ingest、extract、enrich、digest 和 publish 的最近运行结果。", [ + "先点“抓取新闻”确认新闻池有数据。", + "再点“跑完整流水线”生成一条可审计的运行记录。", + ]); } function renderOperationsSources(sources) { @@ -298,7 +315,10 @@ function renderOperationsSources(sources) { ` ) .join("") - : '

当前没有处于冷却、静默或维护中的来源。

'; + : emptyState("来源运行正常", "当前没有冷却、静默或维护中的来源;如果刚接入新源,先刷新来源状态确认配置。", [ + "点“刷新来源”查看每个来源的成功率和最近错误。", + "发现坏来源后再进入维护或静默,避免拖慢整条流水线。", + ]); } function renderOperationsAlerts(sourceAlerts) { @@ -320,7 +340,10 @@ function renderOperationsAlerts(sourceAlerts) { ` ) .join("") - : '

最近没有来源级告警。

'; + : emptyState("没有来源告警", "来源告警会在连续失败、冷却、恢复或通知投递异常时出现。", [ + "先运行“抓正文”暴露抽取失败。", + "如果告警出现,回到来源状态面板确认或静默来源。", + ]); } function renderOperationsPublicationFailures(failures, pending) { @@ -365,7 +388,10 @@ function renderOperationsPublicationFailures(failures, pending) { } refs.operationsPublicationFailures.innerHTML = cards.length ? cards.join("") - : '

最近没有发布失败或待完成记录。

'; + : emptyState("发布队列干净", "最近没有发布失败或待完成记录。发布日报后,这里会显示 Telegram、飞书、公众号和静态站点结果。", [ + "先冻结编辑稿,避免发布时实时重算。", + "再用“发布预览”检查各渠道最终内容。", + ]); } function renderOperations(payload) { @@ -381,9 +407,10 @@ function renderOperations(payload) { } function renderSources(sources) { - refs.sourcesList.innerHTML = sources - .map( - (source) => ` + refs.sourcesList.innerHTML = sources.length + ? sources + .map( + (source) => `
${source.name} @@ -504,8 +531,12 @@ function renderSources(sources) {
` - ) - .join(""); + ) + .join("") + : emptyState("还没有来源状态", "来源列表会展示每个 feed 的区域、语言、成功率、冷却和维护状态。", [ + "点“刷新来源”确认默认来源是否已加载。", + "如果这里仍为空,检查 sources 配置和服务启动日志。", + ]); } function renderDigest(payload) { @@ -516,11 +547,27 @@ function renderDigest(payload) { : payload; const digest = digestPayload?.digest || digestPayload?.payload; if (!digest) { - refs.digestView.innerHTML = '

还没有生成日报。

'; - refs.digestPreviewView.innerHTML = '

还没有预览结果。点击“选稿预览”或生成日报后会显示这里。

'; - refs.digestEditorView.innerHTML = '

先生成预览或打开一份已保存的日报,编辑页才会出现。

'; - refs.digestHistoryView.innerHTML = '

冻结为编辑稿后,这里会显示版本历史和回滚入口。

'; - refs.publishPreviewView.innerHTML = '

打开一份日报或冻结编辑稿后,这里会显示目标平台最终预览。

'; + refs.digestView.innerHTML = emptyState("还没有日报", "生成日报前先完成抓取、正文抽取和选稿预览,避免空内容进入发布链路。", [ + "点“抓取新闻”建立候选池。", + "点“抓正文”补齐正文与摘要。", + "点“生成中文日报”创建第一份日报。", + ]); + refs.digestPreviewView.innerHTML = emptyState("先做选稿预览", "这里会展示入选、suppress、重复副本和 ranked-out 原因。", [ + "点“选稿预览”检查候选质量。", + "必要时在文章列表里置顶、必选或 suppress。", + ]); + refs.digestEditorView.innerHTML = emptyState("等待冻结编辑稿", "生成预览或打开存档日报后,编辑页会出现可保存的发布稿。", [ + "先生成日报或打开一份存档。", + "再点“冻结为编辑稿”锁定发布前版本。", + ]); + refs.digestHistoryView.innerHTML = emptyState("还没有版本历史", "冻结或保存编辑稿后,这里会显示版本、变更摘要、发布记录和回滚入口。", [ + "冻结编辑稿作为 v1。", + "每次保存编辑变更都会追加一个可回滚版本。", + ]); + refs.publishPreviewView.innerHTML = emptyState("等待发布预览", "打开日报或冻结编辑稿后,这里会展示目标平台的最终输出形态。", [ + "先选择发布目标。", + "再点“刷新预览”确认内容和冻结稿一致。", + ]); return; } state.currentDigest = digest; @@ -569,7 +616,10 @@ function renderDigest(payload) { if (digestId) { loadDigestHistory(digestId).catch((error) => logJob("load digest history failed", { error: error.message, digestId })); } else { - refs.digestHistoryView.innerHTML = '

冻结为编辑稿后,这里会显示版本历史和回滚入口。

'; + refs.digestHistoryView.innerHTML = emptyState("还没有版本历史", "冻结为编辑稿后,这里会显示版本历史和回滚入口。", [ + "点“冻结为编辑稿”保存当前候选。", + "保存编辑稿后再回到这里审版本差异。", + ]); } loadPublishPreview(digestId).catch((error) => logJob("load publish preview failed", { error: error.message, digestId })); } @@ -611,7 +661,10 @@ function renderDigestPreview(payload) { const decisions = payload?.selection_decisions || []; const summary = payload?.selection_summary || null; if (!decisions.length) { - refs.digestPreviewView.innerHTML = '

还没有预览结果。点击“选稿预览”或生成日报后会显示这里。

'; + refs.digestPreviewView.innerHTML = emptyState("还没有选稿结果", "选稿预览会解释每篇文章为什么入选、被压制、成为重复副本或排在条数外。", [ + "先抓取新闻并抽取正文。", + "点“选稿预览”查看候选决策。", + ]); return; } const summaryLine = summary @@ -645,7 +698,10 @@ function renderDigestEditor(payload) { const snapshot = payload?.editor_snapshot || null; const items = snapshot?.items || []; if (!items.length) { - refs.digestEditorView.innerHTML = '

先生成预览或打开一份已保存的日报,编辑页才会出现。

'; + refs.digestEditorView.innerHTML = emptyState("编辑稿还没准备好", "发布前编辑页只会在有日报候选或已保存快照后出现。", [ + "先生成中文日报或打开存档日报。", + "再冻结为编辑稿并调整顺序、分组和发布摘要。", + ]); return; } const summary = payload?.selection_summary || {}; @@ -713,7 +769,10 @@ function renderDigestHistory(payload) { const versions = payload?.versions || []; const currentVersion = payload?.current_version || 0; if (!versions.length) { - refs.digestHistoryView.innerHTML = '

当前还没有可用的编辑版本历史。

'; + refs.digestHistoryView.innerHTML = emptyState("暂无可回滚版本", "当前日报还没有可用的编辑版本历史。", [ + "保存一次编辑稿生成新版本。", + "发布前确认版本历史里有清晰的变更摘要。", + ]); return; } refs.digestHistoryView.innerHTML = versions @@ -761,7 +820,10 @@ function renderDigestHistory(payload) { function renderPublishPreview(payload) { const targets = payload?.preview_targets?.targets || []; if (!targets.length) { - refs.publishPreviewView.innerHTML = '

选择一份日报后,这里会显示目标平台最终预览。

'; + refs.publishPreviewView.innerHTML = emptyState("选择日报后再预览", "发布预览需要一份已生成或已冻结的日报,以及至少一个发布目标。", [ + "先打开最新日报或冻结编辑稿。", + "选择 Telegram、飞书、公众号草稿或静态站点后刷新预览。", + ]); return; } refs.publishPreviewView.innerHTML = targets @@ -849,7 +911,10 @@ function renderArchive(digests) { ` ) .join("") - : '

还没有存档日报。

'; + : emptyState("还没有存档日报", "生成或冻结日报后,最近的存档会出现在这里,便于回看和重新发布。", [ + "先生成中文日报。", + "冻结编辑稿后再查看版本和发布记录。", + ]); } function publicationTargetLabel(target) { @@ -932,7 +997,10 @@ function renderPublications(publications) { `; }) .join("") - : '

当前筛选下还没有发布记录。

'; + : emptyState("还没有发布记录", "发布历史会显示目标、状态、外部 ID、失败原因和发布后是否过期。", [ + "先打开一份日报并检查发布预览。", + "发布后回到这里确认每个渠道的状态。", + ]); } function articleChips(article) { @@ -1012,7 +1080,10 @@ function renderArticles(articles) { ` ) .join("") - : '

当前筛选下没有文章。

'; + : emptyState("当前没有文章", "文章池为空通常表示还没抓取,或筛选条件太窄。", [ + "点“抓取新闻”拉取最新候选。", + "放宽区域、时间窗口或条数上限后刷新文章列表。", + ]); } function escapeHtml(value) { @@ -1166,7 +1237,10 @@ function renderExtractionOps(articles) { ` ) .join("") - : '

当前筛选下没有需要关注的抽取记录。

'; + : emptyState("没有待处理抽取项", "抽取队列当前没有被筛选出来的 blocked、throttled 或待重试记录。", [ + "点“抓正文”启动正文抽取。", + "取消 due-only 或切换状态筛选,查看全部抽取结果。", + ]); } function renderSourceAlerts(sourceAlerts) { @@ -1200,7 +1274,10 @@ function renderSourceAlerts(sourceAlerts) { ` ) .join("") - : '

最近还没有来源级告警历史。

'; + : emptyState("暂无来源告警历史", "来源连续失败、恢复或告警投递异常后,历史记录会出现在这里。", [ + "先运行抓取和抽取暴露异常来源。", + "有告警后可在来源状态面板确认、静默或进入维护。", + ]); } async function loadStats() { @@ -1238,22 +1315,28 @@ async function loadDigests() { const payload = await fetchJson("/admin/digests?limit=12", { headers: adminHeaders(), }); - renderArchive(payload.digests || []); - if (payload.digests && payload.digests[0]) { - if (state.selectedDigestId) { - const selected = payload.digests.find((item) => item.id === state.selectedDigestId); - renderDigest(selected || payload.digests[0]); - return; - } - renderDigest(payload.digests[0]); + const digests = payload.digests || []; + renderArchive(digests); + if (!digests[0]) { + renderDigest(null); + return; } + if (state.selectedDigestId) { + const selected = digests.find((item) => item.id === state.selectedDigestId); + renderDigest(selected || digests[0]); + return; + } + renderDigest(digests[0]); } async function loadDigestHistory(digestId = null) { const resolvedDigestId = digestId || state.currentDigestPayload?.stored_digest?.id || state.selectedDigestId || null; if (!resolvedDigestId) { - refs.digestHistoryView.innerHTML = '

冻结为编辑稿后,这里会显示版本历史和回滚入口。

'; + refs.digestHistoryView.innerHTML = emptyState("还没有版本历史", "冻结为编辑稿后,这里会显示版本历史和回滚入口。", [ + "先生成日报。", + "再冻结编辑稿建立第一个可回滚版本。", + ]); return; } const payload = await fetchJson(`/admin/digests/${resolvedDigestId}/history?limit=20`, { diff --git a/src/ainews/web/index.html b/src/ainews/web/index.html index ec85e78..be000dd 100644 --- a/src/ainews/web/index.html +++ b/src/ainews/web/index.html @@ -35,6 +35,11 @@

国内外 AI 新闻控制台

所有管理操作都会自动带上 `X-Admin-Token`。

+
+ 1 保存 Token + 2 抓取新闻 + 3 选稿预览 +
diff --git a/src/ainews/web/styles.css b/src/ainews/web/styles.css index 1c9c365..c15d7b8 100644 --- a/src/ainews/web/styles.css +++ b/src/ainews/web/styles.css @@ -65,6 +65,13 @@ body { margin-bottom: 18px; } +.hero > *, +.section-head > *, +.token-row > *, +.toolbar-row > * { + min-width: 0; +} + .eyebrow { margin: 0 0 10px; font: 600 12px/1 "JetBrains Mono", monospace; @@ -125,6 +132,21 @@ button { padding: 8px 11px; } +.start-cues { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.start-cues span { + border-left: 3px solid var(--accent); + border-radius: 10px; + background: rgba(255, 255, 255, 0.46); + color: var(--ink); + font: 700 12px/1.2 "JetBrains Mono", monospace; + padding: 8px 10px; +} + .token-box, .form-grid label { display: grid; @@ -138,6 +160,10 @@ button { flex-wrap: wrap; } +.token-row input { + flex: 1 1 220px; +} + input, select, textarea { @@ -414,6 +440,46 @@ textarea { background: rgba(255, 255, 255, 0.52); } +.empty-state { + border: 1px dashed rgba(142, 31, 10, 0.3); + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.72), rgba(255, 248, 238, 0.5)), + radial-gradient(circle at top right, rgba(219, 61, 27, 0.1), transparent 34%); + padding: 16px; +} + +.empty-state h3 { + font-size: 20px; + letter-spacing: -0.03em; +} + +.empty-state p { + margin: 8px 0 0; + color: var(--muted); + line-height: 1.65; +} + +.empty-kicker { + margin: 0 0 8px !important; + color: var(--accent-deep) !important; + font: 700 11px/1 "JetBrains Mono", monospace; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.empty-steps { + display: grid; + gap: 8px; + margin: 12px 0 0; + padding-left: 18px; + color: var(--ink); +} + +.empty-steps li { + line-height: 1.55; +} + .source-ops-list { display: grid; gap: 8px; @@ -591,7 +657,7 @@ textarea { @media (max-width: 700px) { .shell { - width: min(100vw - 20px, 100%); + width: calc(100vw - 20px); padding-top: 10px; } @@ -600,6 +666,28 @@ textarea { padding: 18px; } + .section-head { + flex-direction: column; + align-items: stretch; + } + + .hero-pills { + display: grid; + justify-items: start; + } + + .hero-pills span { + max-width: 100%; + } + + .token-row { + display: grid; + } + + .token-row .button { + justify-self: start; + } + .form-grid { grid-template-columns: 1fr; } diff --git a/tests/test_web_console.py b/tests/test_web_console.py index cf6b810..5e23d0e 100644 --- a/tests/test_web_console.py +++ b/tests/test_web_console.py @@ -35,6 +35,42 @@ def test_console_exposes_operator_workflow_map(self) -> None: ): self.assertIn(expected, styles) + def test_console_exposes_actionable_empty_states(self) -> None: + index = _read_text("src/ainews/web/index.html") + app = _read_text("src/ainews/web/app.js") + styles = _read_text("src/ainews/web/styles.css") + + for expected in ( + 'class="start-cues"', + "1 保存 Token", + "2 抓取新闻", + "3 选稿预览", + ): + self.assertIn(expected, index) + + for expected in ( + "function emptyState", + "当前没有文章", + "没有待处理抽取项", + "还没有选稿结果", + "还没有发布记录", + "暂无来源告警历史", + "来源运行正常", + "先跑一次流水线", + "renderDigest(null);", + ): + self.assertIn(expected, app) + + for expected in ( + ".empty-state", + ".empty-kicker", + ".empty-steps", + ".start-cues", + "width: calc(100vw - 20px);", + "flex-direction: column;", + ): + self.assertIn(expected, styles) + if __name__ == "__main__": unittest.main()