Files
signalscout-backend/app/ui/home.py
T

568 lines
16 KiB
Python

HOME_PAGE_HTML = """
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SignalScout</title>
<style>
:root {
color-scheme: light;
--bg: oklch(96.8% 0.018 182);
--ink: oklch(22% 0.028 210);
--muted: oklch(50% 0.028 214);
--line: oklch(87% 0.022 190);
--panel: oklch(99% 0.006 190);
--panel-2: oklch(94.5% 0.018 176);
--accent: oklch(52% 0.12 174);
--warn: oklch(67% 0.13 70);
--bad: oklch(58% 0.16 26);
--good: oklch(53% 0.12 150);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(90deg, oklch(92% 0.018 180 / 0.55) 1px, transparent 1px),
linear-gradient(0deg, oklch(92% 0.018 180 / 0.55) 1px, transparent 1px),
var(--bg);
background-size: 28px 28px;
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
letter-spacing: 0;
}
a { color: inherit; }
.shell {
display: grid;
grid-template-columns: 224px minmax(0, 1fr);
min-height: 100vh;
}
.rail {
border-right: 1px solid var(--line);
background: oklch(98% 0.01 180 / 0.82);
padding: 24px 18px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: grid;
gap: 8px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid var(--ink);
background: var(--accent);
color: white;
font-weight: 800;
border-radius: 8px;
box-shadow: 5px 5px 0 oklch(25% 0.02 210);
}
.brand strong {
font-size: 18px;
line-height: 1.1;
}
.brand span,
.nav-note,
.auto-card span {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.nav {
display: grid;
gap: 8px;
margin-top: 24px;
}
.nav button {
border: 0;
width: 100%;
text-align: left;
background: transparent;
padding: 10px 12px;
border-radius: 8px;
color: var(--muted);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.nav button.active {
background: var(--ink);
color: white;
font-weight: 700;
}
.auto-card {
display: grid;
gap: 6px;
margin-top: 24px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: oklch(99% 0.006 180 / 0.78);
}
.auto-card strong {
font-size: 13px;
}
.content {
padding: 28px;
display: grid;
gap: 20px;
align-content: start;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
border-bottom: 1px solid var(--line);
padding-bottom: 20px;
}
h1 {
margin: 0;
font-size: clamp(28px, 4vw, 48px);
line-height: 1;
max-width: 760px;
}
.subtitle {
margin: 12px 0 0;
color: var(--muted);
max-width: 760px;
line-height: 1.7;
}
.pulse {
min-width: 184px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 14px;
background: var(--panel);
color: var(--muted);
line-height: 1.55;
font-size: 13px;
}
.pulse strong {
display: block;
color: var(--ink);
font-size: 18px;
line-height: 1.2;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
gap: 18px;
align-items: start;
}
.view {
display: none;
gap: 18px;
}
.view.active {
display: grid;
}
.view[data-view="signals"].active {
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.panel-head h2 {
margin: 0;
font-size: 15px;
line-height: 1.2;
}
.count {
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 12px;
}
.stack { display: grid; gap: 18px; }
.signals {
display: grid;
min-height: 360px;
}
.signal-row {
display: grid;
gap: 8px;
padding: 16px;
border-bottom: 1px solid var(--line);
background: white;
}
.signal-row:last-child { border-bottom: 0; }
.signal-meta,
.run-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.signal-row h3 {
margin: 0;
font-size: 16px;
line-height: 1.35;
}
.signal-row p {
margin: 0;
color: oklch(34% 0.025 210);
line-height: 1.65;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 9px;
background: oklch(98% 0.01 180);
color: var(--muted);
max-width: 100%;
}
.importance {
color: oklch(19% 0.035 190);
border-color: oklch(72% 0.08 174);
background: oklch(91% 0.045 174);
}
.report {
padding: 18px;
min-height: 520px;
line-height: 1.75;
white-space: pre-wrap;
}
.runs { display: grid; }
.run-row {
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
background: white;
}
.run-row:last-child { border-bottom: 0; }
.state {
font-weight: 700;
color: var(--muted);
}
.state.completed { color: var(--good); }
.state.failed { color: var(--bad); }
.state.running { color: var(--warn); }
.empty {
padding: 28px 18px;
color: var(--muted);
line-height: 1.7;
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.rail {
position: static;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.nav { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.view[data-view="signals"].active { grid-template-columns: 1fr; }
.topbar { display: grid; }
.pulse { min-width: 0; }
}
@media (max-width: 560px) {
.content { padding: 18px; }
.rail { padding: 18px; }
.nav { grid-template-columns: 1fr; }
h1 { font-size: 30px; }
}
</style>
</head>
<body>
<div class="shell">
<aside class="rail">
<div class="brand">
<div class="brand-mark">S</div>
<strong>SignalScout</strong>
<span>AI 情报采集与研判工作台</span>
</div>
<nav class="nav" aria-label="主导航">
<button class="active" type="button" data-view-target="signals">情报</button>
<button type="button" data-view-target="report">日报</button>
<button type="button" data-view-target="runs">运行</button>
</nav>
<div class="auto-card">
<strong>自动整理</strong>
<span>启动后自动运行,之后约每 30 分钟整理一次。</span>
</div>
<p class="nav-note">聚合 AI 新闻、模型发布、Agent 工具和 GitHub 热门项目。</p>
</aside>
<main class="content">
<section class="topbar">
<div>
<h1>今日 AI 情报</h1>
<p class="subtitle">Agent 自动筛选、去重和生成日报;页面会持续更新最新结果。</p>
</div>
<div class="pulse">
<strong id="lastRunText">等待运行</strong>
<span id="lastRunDetail">正在读取运行记录。</span>
</div>
</section>
<section class="view active" data-view="signals">
<div class="panel">
<div class="panel-head">
<h2>情报信号</h2>
<span class="count" id="signalCount">0 条</span>
</div>
<div class="signals" id="signalList">
<div class="empty">正在读取情报。</div>
</div>
</div>
<div class="stack">
<div class="panel">
<div class="panel-head">
<h2>最新日报</h2>
<span class="count" id="reportTime">待生成</span>
</div>
<div class="report" id="reportPreview">正在读取日报。</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>最近运行</h2>
<span class="count" id="runCount">0 次</span>
</div>
<div class="runs" id="runPreview">
<div class="empty">正在读取运行记录。</div>
</div>
</div>
</div>
</section>
<section class="view" data-view="report">
<div class="panel">
<div class="panel-head">
<h2>最新日报</h2>
<span class="count" id="reportTimeFull">待生成</span>
</div>
<div class="report" id="reportBody">正在读取日报。</div>
</div>
</section>
<section class="view" data-view="runs">
<div class="panel">
<div class="panel-head">
<h2>最近运行</h2>
<span class="count" id="runCountFull">0 次</span>
</div>
<div class="runs" id="runList">
<div class="empty">正在读取运行记录。</div>
</div>
</div>
</section>
</main>
</div>
<script>
const navButtons = Array.from(document.querySelectorAll("[data-view-target]"));
const views = Array.from(document.querySelectorAll("[data-view]"));
const signalList = document.querySelector("#signalList");
const signalCount = document.querySelector("#signalCount");
const reportPreview = document.querySelector("#reportPreview");
const reportBody = document.querySelector("#reportBody");
const reportTime = document.querySelector("#reportTime");
const reportTimeFull = document.querySelector("#reportTimeFull");
const runPreview = document.querySelector("#runPreview");
const runList = document.querySelector("#runList");
const runCount = document.querySelector("#runCount");
const runCountFull = document.querySelector("#runCountFull");
const lastRunText = document.querySelector("#lastRunText");
const lastRunDetail = document.querySelector("#lastRunDetail");
const stateText = {
completed: "完成",
failed: "失败",
running: "运行中"
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function formatDate(value) {
if (!value) return "未标注时间";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未标注时间";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function activateView(name, updateHash = true) {
navButtons.forEach((button) => button.classList.toggle("active", button.dataset.viewTarget === name));
views.forEach((view) => view.classList.toggle("active", view.dataset.view === name));
if (updateHash) history.replaceState(null, "", `#${name}`);
}
function renderSignals(signals) {
signalCount.textContent = `${signals.length} 条`;
if (!signals.length) {
signalList.innerHTML = '<div class="empty">Agent 正在按固定节奏自动整理,完成后这里会出现情报信号。</div>';
return;
}
signalList.innerHTML = signals.map((item) => `
<article class="signal-row">
<div class="signal-meta">
<span class="pill">${escapeHtml(item.topic)}</span>
<span class="pill importance">重要度 ${escapeHtml(item.importance)}</span>
<span>${escapeHtml(item.source_name)}</span>
<span>${formatDate(item.published_at || item.created_at)}</span>
</div>
<h3><a href="${escapeHtml(item.source_url)}" target="_blank" rel="noreferrer">${escapeHtml(item.title)}</a></h3>
<p>${escapeHtml(item.summary)}</p>
<div class="signal-meta">
${(item.entities || []).slice(0, 5).map((entity) => `<span class="pill">${escapeHtml(entity)}</span>`).join("")}
</div>
</article>
`).join("");
}
function renderReport(report) {
if (!report) {
reportTime.textContent = "待生成";
reportTimeFull.textContent = "待生成";
reportPreview.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
reportBody.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
return;
}
const timeText = formatDate(report.created_at);
const content = report.content_md || "日报内容为空。";
reportTime.textContent = timeText;
reportTimeFull.textContent = timeText;
reportPreview.textContent = content;
reportBody.textContent = content;
}
function runRows(runs) {
return runs.map((run) => `
<div class="run-row">
<span>#${escapeHtml(run.id)} · ${formatDate(run.started_at)}</span>
<span class="state ${escapeHtml(run.status)}">${escapeHtml(stateText[run.status] || run.status)}</span>
</div>
`).join("");
}
function renderRuns(runs) {
runCount.textContent = `${runs.length} 次`;
runCountFull.textContent = `${runs.length} 次`;
if (!runs.length) {
runPreview.innerHTML = '<div class="empty">Agent 会在启动后自动开始整理。</div>';
runList.innerHTML = '<div class="empty">Agent 会在启动后自动开始整理。</div>';
lastRunText.textContent = "等待运行";
lastRunDetail.textContent = "启动后自动运行,之后约每 30 分钟整理一次。";
return;
}
runPreview.innerHTML = runRows(runs.slice(0, 4));
runList.innerHTML = runRows(runs);
const latest = runs[0];
lastRunText.textContent = stateText[latest.status] || latest.status;
lastRunDetail.textContent = `最近一次:${formatDate(latest.started_at)}`;
}
async function loadDashboard() {
const response = await fetch("/dashboard", { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error("读取情报失败");
const data = await response.json();
renderSignals(data.signals || []);
renderReport(data.latest_report);
renderRuns(data.runs || []);
}
navButtons.forEach((button) => {
button.addEventListener("click", () => activateView(button.dataset.viewTarget));
});
const initialView = window.location.hash.replace("#", "");
if (["signals", "report", "runs"].includes(initialView)) {
activateView(initialView, false);
}
loadDashboard().catch(() => {
signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>';
reportPreview.textContent = "日报读取失败。";
reportBody.textContent = "日报读取失败。";
runPreview.innerHTML = '<div class="empty">运行记录读取失败。</div>';
runList.innerHTML = '<div class="empty">运行记录读取失败。</div>';
});
window.setInterval(loadDashboard, 60000);
</script>
</body>
</html>
"""