新增 SignalScout 首页工作台
This commit is contained in:
+8
-1
@@ -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:
|
||||||
# 健康检查只返回服务身份,避免暴露密钥和内部地址。
|
# 健康检查只返回服务身份,避免暴露密钥和内部地址。
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""业务用户界面页面。"""
|
||||||
+530
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user