Browse Source

feat(web-v3): /runs 与需求合并为「需求浏览器」+ run detail 去掉需求 tab

1 run = 1 需求,故把 /runs 的空右栏改成选中即看需求详情:
- 后端:run 列表项加 demand_name/demand_desc(来自 source_context;有意义的 type 优先、否则去重后的 name)
- /runs 左侧:卡片改可选中(需求名为主、run_id 下沉),默认选最新、筛掉时回退第一条
- /runs 右侧:新增 DemandDetail——本次需求(名+描述+平台/时间/状态)· 凭什么有这个需求(种子词/命中分类/证据样本/验证/挖掘来源,取 pattern_seed)· 这一趟成果(入池/待复看/淘汰,取 dashboard.business_summary)· 进入完整链路按钮
- run detail:删「需求」tab(4 tab:发现旅程/内容资产/学习复盘/策略配置);漏斗数据源段改跳发现旅程;清掉 source_context/pattern_seed 取数与 SourceEvidenceSummary 等死代码

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee 1 tháng trước cách đây
mục cha
commit
aa7ae9f3b4

+ 13 - 0
content_agent/dashboard_service.py

@@ -410,6 +410,17 @@ class DashboardService:
         latest = lifecycle[-1] if lifecycle else {}
         # 派单信息(平台 / 内容形态 / 策略)落在 dispatch 里,顶层取不到——优先顶层、回退 dispatch。
         dispatch = final_output.get("dispatch") or {}
+        # 需求名/描述:来自 source_context.json(1 run = 1 需求)。
+        # ext_data.type 有时是干净标签("中医养生知识需求")、有时是占位垃圾("pattern");
+        # name 是逗号拼接的种子词且常重复("中医养生,中医养生")。取值:有意义的 type 优先,否则用去重后的 name。
+        source_ctx = self._read_json_optional(run_id, "source_context.json") or {}
+        ext_data = source_ctx.get("ext_data") or {}
+        raw_type = str(ext_data.get("type") or "").strip()
+        good_type = raw_type if raw_type and raw_type.lower() != "pattern" else ""
+        name_parts = [s.strip() for s in str(source_ctx.get("name") or "").split(",") if s.strip()]
+        deduped_name = "、".join(dict.fromkeys(name_parts))
+        demand_name = good_type or deduped_name or None
+        demand_desc = ext_data.get("desc") or ext_data.get("reason")
         # 这些 run 没有 lifecycle_ 事件,started_at 才一直为空;改成从所有事件的 created_at 取首尾,
         # 作为运行起止时间(ISO8601 同时区可按字典序求 min/max)。
         event_times = [str(row.get("created_at")) for row in run_events if row.get("created_at")]
@@ -422,6 +433,8 @@ class DashboardService:
             "platform_mode": final_output.get("platform_mode") or dispatch.get("runtime_stage"),
             "content_format": final_output.get("content_format") or dispatch.get("content_format"),
             "strategy_version": final_output.get("strategy_version") or dispatch.get("strategy_version"),
+            "demand_name": demand_name,
+            "demand_desc": demand_desc,
             "validation_status": final_output.get("validation_status"),
             "error_code": latest.get("error_code"),
             "started_at": (latest.get("created_at") or (min(event_times) if event_times else None)),

+ 2 - 0
content_agent/schemas.py

@@ -92,6 +92,8 @@ class RunListItem(BaseModel):
     platform_mode: str | None = None
     content_format: str | None = None
     strategy_version: str | None = None
+    demand_name: str | None = None
+    demand_desc: str | None = None
     validation_status: str | None = None
     error_code: str | None = None
     started_at: str | None = None

+ 42 - 0
web/app/globals.css

@@ -2016,3 +2016,45 @@ a {
 }
 .asset-verdict-btn.pool { border-color: #bfe0cd; color: #5a9d7c; }
 .asset-verdict-btn.reject { border-color: #e6c2bd; color: #b07c75; }
+
+/* ===== /runs 列表卡:需求名为主、可选中 ===== */
+.run-card-demand { font-size: 14.5px; font-weight: 950; color: #172033; line-height: 1.35; }
+.run-card.active .run-card-demand { color: #2360ad; }
+.run-card-id { font-family: "SFMono-Regular", ui-monospace, Menlo, monospace; font-size: 11px; color: #9aa6ba; }
+
+/* ===== /runs 右侧:需求说明书 ===== */
+.demand-detail { display: flex; flex-direction: column; gap: 14px; }
+.demand-head { display: flex; flex-direction: column; gap: 7px; padding-bottom: 14px; border-bottom: 1px solid #eef1f6; }
+.demand-eyebrow { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 900; color: #2360ad; }
+.demand-name { margin: 0; font-size: 23px; font-weight: 950; color: #172033; line-height: 1.25; }
+.demand-desc { margin: 0; font-size: 13px; line-height: 1.65; color: #51607a; }
+.demand-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 2px; }
+.demand-chip { font-size: 12px; font-weight: 800; color: #2360ad; background: #eaf3ff; border: 1px solid #cfe0f7; border-radius: 999px; padding: 3px 11px; }
+.demand-chip.ghost { color: #5d6b82; background: #f1f4f9; border-color: #e0e6f0; }
+.demand-runid { font-family: "SFMono-Regular", ui-monospace, Menlo, monospace; font-size: 11px; color: #9aa6ba; margin-left: auto; }
+
+.demand-card { border: 1px solid #e4e9f2; border-radius: 13px; background: #ffffff; padding: 14px 16px; }
+.demand-card-title { display: inline-flex; align-items: center; gap: 7px; margin: 0 0 12px; font-size: 14px; font-weight: 900; color: #172033; }
+
+.demand-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+.demand-fact { display: flex; flex-direction: column; gap: 3px; padding: 9px 12px; border: 1px solid #eef1f6; border-radius: 10px; background: #fbfcfe; }
+.demand-fact span { font-size: 11px; font-weight: 800; color: #8491a6; }
+.demand-fact strong { font-size: 13px; font-weight: 900; color: #2c3550; word-break: break-word; }
+
+.demand-yield { display: flex; gap: 10px; }
+.demand-yield-stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 12px 8px; border-radius: 11px; border: 1px solid #e0e6f0; }
+.demand-yield-stat span { font-size: 11.5px; font-weight: 800; color: #6d7c93; }
+.demand-yield-stat strong { font-size: 24px; font-weight: 950; line-height: 1; }
+.demand-yield-stat.pool { background: #f0fbf4; border-color: #bfe6cf; }
+.demand-yield-stat.pool strong { color: #12805a; }
+.demand-yield-stat.review { background: #fff9ec; border-color: #f0dca6; }
+.demand-yield-stat.review strong { color: #9a6512; }
+.demand-yield-stat.reject { background: #fdf1f0; border-color: #f1c9c5; }
+.demand-yield-stat.reject strong { color: #ad2e23; }
+
+.demand-enter {
+  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+  padding: 12px 16px; border-radius: 11px; border: 1px solid #2360ad;
+  background: #2360ad; color: #ffffff; font-size: 13.5px; font-weight: 900; cursor: pointer;
+}
+.demand-enter:hover { background: #1d5396; border-color: #1d5396; }

+ 145 - 0
web/features/runs/DemandDetail.tsx

@@ -0,0 +1,145 @@
+"use client";
+
+// /runs 右侧:选中某条 run(= 一条需求)后,就地展示「需求说明书」——
+// 这次要满足什么需求 / 凭什么有这个需求(Pattern 证据)/ 这一趟的成果 / 进入完整链路。
+// 数据模型 1 run = 1 需求;基础名/描述来自列表项,证据与成果按需取数。
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { ArrowRight, FlaskConical, Sprout, Target } from "lucide-react";
+import { StatusBadge } from "@/components/badges/StatusBadge";
+import { getDashboard, getRuntimeFile } from "@/lib/api/client";
+import { platformLabel } from "@/lib/platform/content";
+import { compactValue } from "@/lib/status/status";
+import type { DashboardResponse, RunListItem } from "@/lib/api/types";
+
+type AnyRec = Record<string, unknown>;
+
+function rec(v: unknown): AnyRec {
+  return v && typeof v === "object" ? (v as AnyRec) : {};
+}
+
+function fmtTime(iso?: string | null): string {
+  if (!iso) return "运行时间未知";
+  const d = new Date(iso);
+  if (Number.isNaN(d.getTime())) return "运行时间未知";
+  const p = (n: number) => String(n).padStart(2, "0");
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+function firstCategoryPath(seed: AnyRec): string {
+  const bindings = Array.isArray(seed.category_bindings) ? seed.category_bindings : [];
+  const first = rec(bindings[0]);
+  const path = first.category_path || first.category_full_path;
+  if (path) return String(path);
+  const itemsets = Array.isArray(seed.itemsets) ? seed.itemsets : [];
+  const fi = rec(itemsets[0]);
+  return fi.category_path ? String(fi.category_path) : "—";
+}
+
+export function DemandDetail({ item }: { item: RunListItem }) {
+  const runId = item.run_id;
+  const [dash, setDash] = useState<DashboardResponse | null>(null);
+  const [seed, setSeed] = useState<AnyRec | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    let alive = true;
+    setLoading(true);
+    setDash(null);
+    setSeed(null);
+    Promise.allSettled([getDashboard(runId), getRuntimeFile(runId, "pattern_seed_pack.json", 80)]).then(
+      ([d, s]) => {
+        if (!alive) return;
+        if (d.status === "fulfilled") setDash(d.value);
+        if (s.status === "fulfilled") setSeed(rec(s.value.data));
+        setLoading(false);
+      }
+    );
+    return () => {
+      alive = false;
+    };
+  }, [runId]);
+
+  const biz = rec(dash?.business_summary);
+  const seedTerms = seed && Array.isArray(seed.seed_terms) ? seed.seed_terms : [];
+  const matched = seed && Array.isArray(seed.matched_post_ids) ? seed.matched_post_ids.length : null;
+
+  return (
+    <div className="demand-detail">
+      <header className="demand-head">
+        <span className="demand-eyebrow"><Target size={13} /> 本次需求</span>
+        <h2 className="demand-name">{item.demand_name || "未命名需求"}</h2>
+        {item.demand_desc ? <p className="demand-desc">{item.demand_desc}</p> : null}
+        <div className="demand-badges">
+          {item.platform ? <span className="demand-chip">{platformLabel(item.platform)}</span> : null}
+          <span className="demand-chip ghost">🕒 {fmtTime(item.started_at)}</span>
+          <StatusBadge status={item.status} />
+          <span className="demand-runid">{runId}</span>
+        </div>
+      </header>
+
+      {/* 凭什么有这个需求 */}
+      <section className="demand-card">
+        <h3 className="demand-card-title"><Sprout size={15} /> 凭什么有这个需求</h3>
+        {loading ? (
+          <div className="loading-state">加载中…</div>
+        ) : seed ? (
+          <div className="demand-facts">
+            <div className="demand-fact">
+              <span>种子词</span>
+              <strong>{seedTerms.length ? seedTerms.map(String).join("、") : "—"}</strong>
+            </div>
+            <div className="demand-fact">
+              <span>命中分类</span>
+              <strong>{firstCategoryPath(seed)}</strong>
+            </div>
+            <div className="demand-fact">
+              <span>证据样本</span>
+              <strong>{matched != null ? `${matched} 条匹配` : "—"}</strong>
+            </div>
+            <div className="demand-fact">
+              <span>验证状态</span>
+              <strong>{compactValue(seed.validation_status) || "—"}</strong>
+            </div>
+            <div className="demand-fact">
+              <span>挖掘来源</span>
+              <strong>{compactValue(seed.pattern_source_system) || "—"}</strong>
+            </div>
+          </div>
+        ) : (
+          <div className="empty-state">这条 run 没有 Pattern 证据(可能是早期/冒烟数据)。</div>
+        )}
+      </section>
+
+      {/* 这一趟的成果 */}
+      <section className="demand-card">
+        <h3 className="demand-card-title"><FlaskConical size={15} /> 这一趟的成果</h3>
+        {loading ? (
+          <div className="loading-state">加载中…</div>
+        ) : dash ? (
+          <div className="demand-yield">
+            <div className="demand-yield-stat pool">
+              <span>入池</span>
+              <strong>{compactValue(biz.kept_count)}</strong>
+            </div>
+            <div className="demand-yield-stat review">
+              <span>待复看</span>
+              <strong>{compactValue(biz.review_count)}</strong>
+            </div>
+            <div className="demand-yield-stat reject">
+              <span>淘汰</span>
+              <strong>{compactValue(biz.rejected_count)}</strong>
+            </div>
+          </div>
+        ) : (
+          <div className="empty-state">暂无成果数据。</div>
+        )}
+      </section>
+
+      <Link className="demand-enter" href={`/runs/${encodeURIComponent(runId)}`}>
+        进入完整链路(发现旅程 / 内容资产 / 学习复盘 / 策略配置)
+        <ArrowRight size={16} />
+      </Link>
+    </div>
+  );
+}

+ 5 - 113
web/features/runs/RunDashboardPage.tsx

@@ -44,8 +44,6 @@ type DashboardData = {
   contentItems: ContentItemsResponse;
   timeline: TimelineResponse;
   runtimeFiles: RuntimeFilesResponse;
-  sourceContext: RuntimeFileResponse | null;
-  patternSeed: RuntimeFileResponse | null;
 };
 
 type DrawerContent =
@@ -54,16 +52,6 @@ type DrawerContent =
   | { kind: "walk"; title: string; payload: unknown }
   | null;
 
-async function optionalRuntimeFile(runId: string, filename: string): Promise<RuntimeFileResponse | null> {
-  try {
-    return await getRuntimeFile(runId, filename, 80);
-  } catch (err) {
-    // 可选文件加载失败不再静默:控制台留痕,页面对应区块显示"缺失"。
-    console.warn(`runtime file load failed: ${filename}`, err);
-    return null;
-  }
-}
-
 export function RunDashboardPage({ runId }: { runId: string }) {
   const [activeStage, setActiveStage] = useState("walk");
   const [data, setData] = useState<DashboardData | null>(null);
@@ -76,16 +64,14 @@ export function RunDashboardPage({ runId }: { runId: string }) {
     setLoading(true);
     setError(null);
     try {
-      const [dashboard, queries, contentItems, timeline, runtimeFiles, sourceContext, patternSeed] = await Promise.all([
+      const [dashboard, queries, contentItems, timeline, runtimeFiles] = await Promise.all([
         getDashboard(runId),
         getQueries(runId),
         getContentItems(runId),
         getTimeline(runId),
-        getRuntimeFiles(runId),
-        optionalRuntimeFile(runId, "source_context.json"),
-        optionalRuntimeFile(runId, "pattern_seed_pack.json")
+        getRuntimeFiles(runId)
       ]);
-      setData({ dashboard, queries, contentItems, timeline, runtimeFiles, sourceContext, patternSeed });
+      setData({ dashboard, queries, contentItems, timeline, runtimeFiles });
     } catch (err) {
       setError(err instanceof Error ? err.message : String(err));
     } finally {
@@ -207,9 +193,9 @@ function RunMetaInline({ runId, timeline }: { runId: string; timeline: TimelineR
   );
 }
 
-// 激进合一:被折叠的 query/platform/judge 漏斗段点击后跳「发现旅程」。
+// 激进合一:被折叠的漏斗段(数据源/query/platform/judge)点击后跳「发现旅程」。
 function funnelTarget(stageId: string): string {
-  if (stageId === "query" || stageId === "platform" || stageId === "judge") return "walk";
+  if (stageId === "source" || stageId === "query" || stageId === "platform" || stageId === "judge") return "walk";
   return stageId;
 }
 
@@ -246,7 +232,6 @@ function FunnelStrip({
 
 // 第二层:5 面板导航(前端常量驱动)。发现旅程卡副行挂 Query/内容/判定 三计数。
 const PANELS: Array<{ id: string; label: string }> = [
-  { id: "source", label: "需求" },
   { id: "walk", label: "发现旅程" },
   { id: "asset", label: "内容资产" },
   { id: "learning", label: "学习复盘" },
@@ -293,81 +278,6 @@ function stageCount(dashboard: DashboardResponse, stageId: string): number | nul
   return null;
 }
 
-function runtimeData(file: RuntimeFileResponse | null): Record<string, unknown> {
-  if (!file?.data || typeof file.data !== "object") {
-    return {};
-  }
-  return file.data as Record<string, unknown>;
-}
-
-function evidencePackFrom(sourceContext: Record<string, unknown>): Record<string, unknown> {
-  const extData = sourceContext.ext_data;
-  if (extData && typeof extData === "object" && "evidence_pack" in extData) {
-    const evidencePack = (extData as Record<string, unknown>).evidence_pack;
-    return evidencePack && typeof evidencePack === "object" ? evidencePack as Record<string, unknown> : {};
-  }
-  return {};
-}
-
-function firstCategoryPath(patternSeed: Record<string, unknown>): string {
-  const bindings = Array.isArray(patternSeed.category_bindings) ? patternSeed.category_bindings : [];
-  const first = bindings[0];
-  if (first && typeof first === "object") {
-    return compactValue((first as Record<string, unknown>).category_path || (first as Record<string, unknown>).category_full_path);
-  }
-  const itemsets = Array.isArray(patternSeed.itemsets) ? patternSeed.itemsets : [];
-  const firstItemset = itemsets[0];
-  if (firstItemset && typeof firstItemset === "object") {
-    return compactValue((firstItemset as Record<string, unknown>).category_path);
-  }
-  return "缺失";
-}
-
-function SourceEvidenceSummary({
-  sourceContext,
-  patternSeed
-}: {
-  sourceContext: RuntimeFileResponse | null;
-  patternSeed: RuntimeFileResponse | null;
-}) {
-  const source = runtimeData(sourceContext);
-  const seed = runtimeData(patternSeed);
-  const evidencePack = evidencePackFrom(source);
-  const extData = source.ext_data && typeof source.ext_data === "object"
-    ? source.ext_data as Record<string, unknown>
-    : {};
-  const seedTerms = Array.isArray(seed.seed_terms) ? seed.seed_terms : evidencePack.seed_terms;
-  const matchedPostIds = Array.isArray(seed.matched_post_ids) ? seed.matched_post_ids : evidencePack.matched_post_ids;
-  return (
-    <div className="source-summary-grid">
-      <div>
-        <span>需求名称</span>
-        <strong>{compactValue(source.name || extData.type)}</strong>
-        <small>{compactValue(extData.desc || extData.reason)}</small>
-      </div>
-      <div>
-        <span>Pattern 来源</span>
-        <strong>{compactValue(seed.pattern_source_system || evidencePack.pattern_source_system)}</strong>
-        <small>Pattern 执行 ID:{compactValue(seed.pattern_execution_id || evidencePack.pattern_execution_id)}</small>
-      </div>
-      <div>
-        <span>种子词</span>
-        <strong>{compactValue(seedTerms)}</strong>
-        <small>Itemset:{compactValue(seed.itemset_ids || evidencePack.itemset_ids)}</small>
-      </div>
-      <div>
-        <span>Pattern 分类路径</span>
-        <strong>{firstCategoryPath(seed)}</strong>
-        <small>来源样本:{compactValue(seed.source_post_id || evidencePack.source_post_id)}</small>
-      </div>
-      <div>
-        <span>证据规模</span>
-        <strong>{Array.isArray(matchedPostIds) ? `${matchedPostIds.length} 条匹配样本` : "缺失"}</strong>
-        <small>验证状态:{compactValue(seed.validation_status || evidencePack.validation_status || source.validation_status)}</small>
-      </div>
-    </div>
-  );
-}
 
 function StagePanel({
   activeStage,
@@ -382,24 +292,6 @@ function StagePanel({
   onOpenRuntimeFile: (filename: string) => void;
   onOpenDrawer: (drawer: DrawerContent) => void;
 }) {
-  if (activeStage === "source") {
-    return (
-      <BusinessSection title="数据源结论" icon={<Target size={17} />}>
-        <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "source")} />
-        <SourceEvidenceSummary sourceContext={data.sourceContext} patternSeed={data.patternSeed} />
-        <div className="business-action-row">
-          <button className="text-button" onClick={() => onOpenRuntimeFile("source_context.json")} type="button">
-            <FileJson size={15} />
-            查看需求证据
-          </button>
-          <button className="text-button" onClick={() => onOpenRuntimeFile("pattern_seed_pack.json")} type="button">
-            <FileJson size={15} />
-            查看 Pattern 种子
-          </button>
-        </div>
-      </BusinessSection>
-    );
-  }
   if (activeStage === "walk") {
     return (
       <BusinessSection title="内容发现旅程" icon={<GitBranch size={17} />}>

+ 35 - 34
web/features/runs/RunListPage.tsx

@@ -1,6 +1,5 @@
 "use client";
 
-import Link from "next/link";
 import { Search } from "lucide-react";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { AppShell } from "@/components/layout/AppShell";
@@ -8,6 +7,7 @@ import { DataOriginBadge, StatusBadge } from "@/components/badges/StatusBadge";
 import { listRuns } from "@/lib/api/client";
 import { platformLabel } from "@/lib/platform/content";
 import { statusLabel } from "@/lib/status/status";
+import { DemandDetail } from "@/features/runs/DemandDetail";
 import type { RunListItem, RunListResponse } from "@/lib/api/types";
 
 // run 级状态仅此 4 种(RunState.status 合同);pending/rule_blocked 是内容/决策级状态,不在此列。
@@ -19,6 +19,7 @@ export function RunListPage() {
   const [data, setData] = useState<RunListResponse | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [loading, setLoading] = useState(true);
+  const [selectedId, setSelectedId] = useState<string | null>(null);
 
   const load = useCallback(async () => {
     setLoading(true);
@@ -60,6 +61,19 @@ export function RunListPage() {
     });
   }, [data, search]);
 
+  // 默认选中第一条(最新);当前选中项被筛掉时回退到第一条。
+  useEffect(() => {
+    if (!items.length) {
+      setSelectedId(null);
+      return;
+    }
+    if (!selectedId || !items.some((it) => it.run_id === selectedId)) {
+      setSelectedId(items[0].run_id);
+    }
+  }, [items, selectedId]);
+
+  const selected = items.find((it) => it.run_id === selectedId) || null;
+
   return (
     <AppShell onRefresh={load}>
       <section className="filter-bar">
@@ -96,34 +110,22 @@ export function RunListPage() {
           {error ? <div className="error-state">{error}</div> : null}
           {!loading && !error && !items.length ? <div className="empty-state">没有匹配的 run</div> : null}
           {!loading && !error
-            ? items.map((item) => <RunCard item={item} key={item.run_id} />)
+            ? items.map((item) => (
+                <RunCard
+                  item={item}
+                  active={item.run_id === selectedId}
+                  onSelect={() => setSelectedId(item.run_id)}
+                  key={item.run_id}
+                />
+              ))
             : null}
         </div>
         <div className="detail-panel">
-          <div className="detail-header">
-            <div className="detail-title">
-              <span>Web Dashboard</span>
-              <strong>选择一个 run 查看完整链路</strong>
-            </div>
-          </div>
-          <div className="metrics-grid">
-            <div className="metric">
-              <span>Total</span>
-              <strong>{data?.total ?? 0}</strong>
-            </div>
-            <div className="metric">
-              <span>Shown</span>
-              <strong>{items.length}</strong>
-            </div>
-          </div>
-          <div className="section">
-            <div className="section-title">
-              <strong>数据边界</strong>
-            </div>
-            <p className="muted">
-              页面只展示后端返回的真实 run 数据;如果 DB 或 runtime 缺少完整链路,这里会保持空状态或失败诊断。
-            </p>
-          </div>
+          {selected ? (
+            <DemandDetail item={selected} key={selected.run_id} />
+          ) : (
+            <div className="empty-state">左侧选择一条需求查看详情。</div>
+          )}
         </div>
       </section>
     </AppShell>
@@ -146,7 +148,7 @@ function fmtRunTime(iso?: string | null): string {
   return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
 }
 
-function RunCard({ item }: { item: RunListItem }) {
+function RunCard({ item, active, onSelect }: { item: RunListItem; active: boolean; onSelect: () => void }) {
   // 数据源行:平台 · 内容形态 · 策略(用户选「显示数据源平台+格式」取代未落库的服务器字段)。
   const source = [
     item.platform ? platformLabel(item.platform) : "",
@@ -155,21 +157,20 @@ function RunCard({ item }: { item: RunListItem }) {
   ].filter(Boolean);
   const runTime = fmtRunTime(item.started_at);
   return (
-    <Link className="run-card" href={`/runs/${encodeURIComponent(item.run_id)}`}>
+    <button type="button" className={`run-card ${active ? "active" : ""}`} onClick={onSelect}>
       <div className="run-card-title">
-        <span>{item.run_id}</span>
+        <span className="run-card-demand">{item.demand_name || item.run_id}</span>
         <StatusBadge status={item.status} />
       </div>
       <div className="run-card-meta">
         <span className="run-card-time">🕒 {runTime || "运行时间未知"}</span>
+        {source.length ? <span>{source.join(" · ")}</span> : null}
       </div>
       <div className="run-card-meta">
-        {source.length ? <span>{source.join(" · ")}</span> : <span>数据源未知</span>}
-        {item.validation_status ? (
-          <span>校验{statusLabel(item.validation_status)}</span>
-        ) : null}
+        <span className="run-card-id">{item.run_id}</span>
+        {item.validation_status ? <span>校验{statusLabel(item.validation_status)}</span> : null}
         {item.error_code ? <span className="run-card-err">{item.error_code}</span> : null}
       </div>
-    </Link>
+    </button>
   );
 }

+ 2 - 0
web/lib/api/types.ts

@@ -9,6 +9,8 @@ export type RunListItem = {
   platform_mode?: string | null;
   content_format?: string | null;
   strategy_version?: string | null;
+  demand_name?: string | null;
+  demand_desc?: string | null;
   validation_status?: string | null;
   error_code?: string | null;
   started_at?: string | null;