新增 SignalScout 首页工作台

This commit is contained in:
Codex
2026-07-08 22:22:33 +08:00
parent 067a82e29d
commit 05d98dd576
4 changed files with 549 additions and 1 deletions
+8 -1
View File
@@ -4,7 +4,7 @@ from datetime import date
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -22,10 +22,17 @@ from app.schemas.agent import (
TopicCreate, TopicCreate,
TopicRead, TopicRead,
) )
from app.ui.home import HOME_PAGE_HTML
router = APIRouter() router = APIRouter()
@router.get("/", response_class=HTMLResponse)
def home() -> HTMLResponse:
# 根路径返回业务工作台,避免用户直接打开域名时看到接口404。
return HTMLResponse(HOME_PAGE_HTML)
@router.get("/health") @router.get("/health")
def health(settings: Settings = Depends(get_settings)) -> dict: def health(settings: Settings = Depends(get_settings)) -> dict:
# 健康检查只返回服务身份,避免暴露密钥和内部地址。 # 健康检查只返回服务身份,避免暴露密钥和内部地址。
+1
View File
@@ -0,0 +1 @@
"""业务用户界面页面。"""
+530
View File
@@ -0,0 +1,530 @@
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);
--accent-ink: oklch(19% 0.035 190);
--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 {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.nav {
display: grid;
gap: 8px;
margin-top: 24px;
}
.nav a {
text-decoration: none;
padding: 10px 12px;
border-radius: 8px;
color: var(--muted);
font-size: 14px;
}
.nav a.active {
background: var(--ink);
color: white;
}
.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;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
button {
border: 1px solid var(--ink);
border-radius: 8px;
background: var(--accent);
color: white;
min-height: 42px;
padding: 0 16px;
font-weight: 700;
cursor: pointer;
box-shadow: 4px 4px 0 var(--ink);
transition: transform 160ms ease, box-shadow 160ms ease;
}
button:hover { transform: translate(-1px, -1px); box-shadow: 5px 5px 0 var(--ink); }
button:disabled { opacity: 0.58; cursor: wait; transform: none; box-shadow: 2px 2px 0 var(--ink); }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 18px;
align-items: start;
}
.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;
height: 24px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0 9px;
background: oklch(98% 0.01 180);
color: var(--muted);
max-width: 100%;
}
.importance {
color: var(--accent-ink);
border-color: oklch(72% 0.08 174);
background: oklch(91% 0.045 174);
}
.report {
padding: 18px;
min-height: 320px;
line-height: 1.75;
white-space: pre-wrap;
}
.report h1,
.report h2,
.report h3 {
font-size: 16px;
margin: 0 0 10px;
line-height: 1.35;
}
.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;
}
.toast {
position: fixed;
right: 22px;
bottom: 22px;
max-width: 360px;
background: var(--ink);
color: white;
padding: 12px 14px;
border-radius: 8px;
opacity: 0;
transform: translateY(12px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
line-height: 1.55;
z-index: 20;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
@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)); }
.layout { grid-template-columns: 1fr; }
.topbar { display: grid; }
.actions { justify-content: flex-start; }
}
@media (max-width: 560px) {
.content { padding: 18px; }
.rail { padding: 18px; }
.nav { grid-template-columns: 1fr; }
h1 { font-size: 30px; }
button { width: 100%; }
}
</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="主导航">
<a class="active" href="#signals">情报</a>
<a href="#report">日报</a>
<a href="#runs">运行</a>
</nav>
<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="actions">
<button id="runButton" type="button">采集今日情报</button>
</div>
</section>
<section class="layout">
<div class="panel" id="signals">
<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" id="report">
<div class="panel-head">
<h2>最新日报</h2>
<span class="count" id="reportTime">待生成</span>
</div>
<div class="report" id="reportBody">正在读取日报。</div>
</div>
<div class="panel" id="runs">
<div class="panel-head">
<h2>最近运行</h2>
<span class="count" id="runCount">0 次</span>
</div>
<div class="runs" id="runList">
<div class="empty">正在读取运行记录。</div>
</div>
</div>
</div>
</section>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const signalList = document.querySelector("#signalList");
const signalCount = document.querySelector("#signalCount");
const reportBody = document.querySelector("#reportBody");
const reportTime = document.querySelector("#reportTime");
const runList = document.querySelector("#runList");
const runCount = document.querySelector("#runCount");
const runButton = document.querySelector("#runButton");
const toast = document.querySelector("#toast");
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 showToast(message) {
toast.textContent = message;
toast.classList.add("show");
window.setTimeout(() => toast.classList.remove("show"), 3200);
}
function renderSignals(signals) {
signalCount.textContent = `${signals.length} 条`;
if (!signals.length) {
signalList.innerHTML = '<div class="empty">还没有情报信号,可以先采集今日情报。</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 = "待生成";
reportBody.textContent = "还没有日报,可以先采集今日情报。";
return;
}
reportTime.textContent = formatDate(report.created_at);
reportBody.textContent = report.content_md || "日报内容为空。";
}
function renderRuns(runs) {
runCount.textContent = `${runs.length} 次`;
if (!runs.length) {
runList.innerHTML = '<div class="empty">还没有运行记录。</div>';
return;
}
runList.innerHTML = 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("");
}
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 || []);
}
async function runAgent() {
runButton.disabled = true;
runButton.textContent = "采集中";
showToast("Agent 已开始采集今日情报。");
try {
const response = await fetch("/agent/runs/daily", { method: "POST" });
if (!response.ok) {
const body = await response.text();
throw new Error(body || "采集失败");
}
showToast("今日情报采集完成。");
await loadDashboard();
} catch (error) {
showToast(error.message || "采集失败,请查看日志。");
} finally {
runButton.disabled = false;
runButton.textContent = "采集今日情报";
}
}
runButton.addEventListener("click", runAgent);
loadDashboard().catch(() => {
signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>';
reportBody.textContent = "日报读取失败。";
runList.innerHTML = '<div class="empty">运行记录读取失败。</div>';
});
</script>
</body>
</html>
"""
+10
View File
@@ -11,3 +11,13 @@ def test_health_endpoint_returns_app_identity() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "ok" assert response.json()["status"] == "ok"
assert response.json()["app"] == "SignalScout" assert response.json()["app"] == "SignalScout"
def test_home_page_returns_dashboard_shell() -> None:
# 根路径应该展示业务工作台,而不是返回接口404。
with TestClient(create_app()) as client:
response = client.get("/")
assert response.status_code == 200
assert "SignalScout" in response.text
assert "今日 AI 情报" in response.text