Просмотр исходного кода

feat(web): /runs 改为按需求维度展示(左可搜需求列表 + 右该需求各渠道多次run带时间)

后端:run 列表 LEFT JOIN demand_content 取 demand_name + 返回 demand_content_id(原 DB 路径不返回需求,
导致 demand_name=None);本地路径补 demand_content_id;RunListItem schema 加 demand_content_id。
前端:RunListPage 重写为两栏——左按 demand_content_id 聚合的可搜需求列表(名称/ID 过滤、最新时间倒序),
右选中需求按平台分组、每组按 started_at 倒序列出每一次 run(北京时间+状态+链接),同需求同渠道多次都列出。
老 run(无 demand_content_id)归'未知需求'。复用 platformLabel/状态chip/北京时间 helper。521 passed,tsc 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee 2 недель назад
Родитель
Сommit
abdf405c48
5 измененных файлов с 292 добавлено и 24 удалено
  1. 16 2
      content_agent/dashboard_service.py
  2. 1 0
      content_agent/schemas.py
  3. 141 0
      web2/app/globals.css
  4. 133 22
      web2/features/RunListPage.tsx
  5. 1 0
      web2/lib/api/types.ts

+ 16 - 2
content_agent/dashboard_service.py

@@ -378,8 +378,11 @@ class DashboardService:
             "(SELECT p.policy_run_id FROM `content_agent_policy_runs` p "
             "(SELECT p.policy_run_id FROM `content_agent_policy_runs` p "
             "WHERE p.run_id = r.run_id ORDER BY p.id DESC LIMIT 1) AS policy_run_id, "
             "WHERE p.run_id = r.run_id ORDER BY p.id DESC LIMIT 1) AS policy_run_id, "
             "r.status, r.current_step, r.platform, r.platform_mode, r.strategy_version, "
             "r.status, r.current_step, r.platform, r.platform_mode, r.strategy_version, "
-            "r.validation_status, r.error_code, r.started_at, r.completed_at "
-            f"FROM `content_agent_runs` r {where_sql} "
+            "r.validation_status, r.error_code, r.started_at, r.completed_at, "
+            "r.demand_content_id, dc.name AS demand_name, dc.reason AS demand_desc "
+            f"FROM `content_agent_runs` r "
+            "LEFT JOIN `demand_content` dc ON dc.id = r.demand_content_id "
+            f"{where_sql} "
             "ORDER BY r.started_at DESC, r.id DESC LIMIT %s OFFSET %s",
             "ORDER BY r.started_at DESC, r.id DESC LIMIT %s OFFSET %s",
             tuple([*params, page_size, (page - 1) * page_size]),
             tuple([*params, page_size, (page - 1) * page_size]),
         )
         )
@@ -457,6 +460,7 @@ class DashboardService:
             "platform_mode": final_output.get("platform_mode") or dispatch.get("runtime_stage"),
             "platform_mode": final_output.get("platform_mode") or dispatch.get("runtime_stage"),
             "content_format": final_output.get("content_format") or dispatch.get("content_format"),
             "content_format": final_output.get("content_format") or dispatch.get("content_format"),
             "strategy_version": final_output.get("strategy_version") or dispatch.get("strategy_version"),
             "strategy_version": final_output.get("strategy_version") or dispatch.get("strategy_version"),
+            "demand_content_id": _int_or_none(source_ctx.get("demand_content_id")),
             "demand_name": demand_name,
             "demand_name": demand_name,
             "demand_desc": demand_desc,
             "demand_desc": demand_desc,
             "validation_status": final_output.get("validation_status"),
             "validation_status": final_output.get("validation_status"),
@@ -620,6 +624,9 @@ def _db_run_row_to_item(row: dict[str, Any]) -> dict[str, Any]:
         "error_code": row.get("error_code"),
         "error_code": row.get("error_code"),
         "started_at": row.get("started_at"),
         "started_at": row.get("started_at"),
         "completed_at": row.get("completed_at"),
         "completed_at": row.get("completed_at"),
+        "demand_content_id": row.get("demand_content_id"),
+        "demand_name": row.get("demand_name"),
+        "demand_desc": row.get("demand_desc"),
     }
     }
 
 
 
 
@@ -627,6 +634,13 @@ def _matches(item: dict[str, Any], key: str, expected: str | None) -> bool:
     return not expected or item.get(key) == expected
     return not expected or item.get(key) == expected
 
 
 
 
+def _int_or_none(value: Any) -> int | None:
+    try:
+        return int(str(value).strip()) if value not in (None, "") else None
+    except (TypeError, ValueError):
+        return None
+
+
 def _json_safe(value: Any) -> Any:
 def _json_safe(value: Any) -> Any:
     if isinstance(value, dict):
     if isinstance(value, dict):
         return {key: _json_safe(item) for key, item in value.items()}
         return {key: _json_safe(item) for key, item in value.items()}

+ 1 - 0
content_agent/schemas.py

@@ -92,6 +92,7 @@ class RunListItem(BaseModel):
     platform_mode: str | None = None
     platform_mode: str | None = None
     content_format: str | None = None
     content_format: str | None = None
     strategy_version: str | None = None
     strategy_version: str | None = None
+    demand_content_id: int | None = None
     demand_name: str | None = None
     demand_name: str | None = None
     demand_desc: str | None = None
     demand_desc: str | None = None
     validation_status: str | None = None
     validation_status: str | None = None

+ 141 - 0
web2/app/globals.css

@@ -1022,6 +1022,147 @@ a:focus-visible {
   font-size: 11.5px;
   font-size: 11.5px;
 }
 }
 
 
+.runs-by-demand {
+  display: grid;
+  grid-template-columns: 300px minmax(0, 1fr);
+  gap: 12px;
+  margin: 12px 20px 24px;
+  align-items: start;
+}
+
+@media (max-width: 760px) {
+  .runs-by-demand {
+    grid-template-columns: 1fr;
+  }
+}
+
+.rbd-demands {
+  position: sticky;
+  top: 64px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-height: calc(100vh - 96px);
+}
+
+.rbd-search {
+  width: 100%;
+}
+
+.rbd-demand-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  overflow-y: auto;
+}
+
+.rbd-demand {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  text-align: left;
+  padding: 10px 12px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  background: #fff;
+  cursor: pointer;
+}
+
+.rbd-demand:hover {
+  border-color: #bfdbfe;
+  background: #f8fafc;
+}
+
+.rbd-demand.active {
+  border-color: #60a5fa;
+  background: #eff6ff;
+}
+
+.rbd-demand strong {
+  font-size: 13px;
+  color: #111827;
+}
+
+.rbd-demand-meta {
+  font-size: 12px;
+  color: var(--muted);
+}
+
+.rbd-demand-platforms {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.rbd-demand-time {
+  font-size: 11.5px;
+  color: #94a3b8;
+}
+
+.rbd-runs {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.rbd-runs-head {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.rbd-runs-head strong {
+  font-size: 15px;
+}
+
+.rbd-platform {
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  background: #fff;
+  padding: 10px 12px;
+}
+
+.rbd-platform-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.rbd-run-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.rbd-run {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  padding: 8px 10px;
+  border: 1px solid #eef2f7;
+  border-radius: 6px;
+  background: #fcfdff;
+}
+
+.rbd-run:hover {
+  border-color: #bfdbfe;
+  background: #f8fafc;
+}
+
+.rbd-run-time {
+  font-size: 12.5px;
+  color: #111827;
+  font-weight: 500;
+}
+
+.rbd-run-id {
+  font-size: 11px;
+  margin-left: auto;
+}
+
 .run-list {
 .run-list {
   margin: 12px 20px 24px;
   margin: 12px 20px 24px;
   display: grid;
   display: grid;

+ 133 - 22
web2/features/RunListPage.tsx

@@ -1,7 +1,7 @@
 "use client";
 "use client";
 
 
 import Link from "next/link";
 import Link from "next/link";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
 import { AppShell } from "@/components/AppShell";
 import { AppShell } from "@/components/AppShell";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
 import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
 import { listRuns } from "@/lib/api/client";
 import { listRuns } from "@/lib/api/client";
@@ -30,10 +30,10 @@ function statusChipClass(value?: string | null): string {
 }
 }
 
 
 function formatBeijingTime(value?: string | null): string {
 function formatBeijingTime(value?: string | null): string {
-  if (!value) return "未记录开始时间";
+  if (!value) return "未记录时间";
   const iso = /z$|[+-]\d{2}:\d{2}$/i.test(value) ? value : `${value}Z`;
   const iso = /z$|[+-]\d{2}:\d{2}$/i.test(value) ? value : `${value}Z`;
   const date = new Date(iso);
   const date = new Date(iso);
-  if (Number.isNaN(date.getTime())) return "开始时间待确认";
+  if (Number.isNaN(date.getTime())) return "时间待确认";
   return `北京时间 ${new Intl.DateTimeFormat("zh-CN", {
   return `北京时间 ${new Intl.DateTimeFormat("zh-CN", {
     timeZone: "Asia/Shanghai",
     timeZone: "Asia/Shanghai",
     year: "numeric",
     year: "numeric",
@@ -45,16 +45,59 @@ function formatBeijingTime(value?: string | null): string {
   }).format(date)}`;
   }).format(date)}`;
 }
 }
 
 
+type DemandGroup = {
+  key: string;
+  id: number | null;
+  name: string;
+  runs: RunListItem[];
+  latest: string;
+};
+
+const PLATFORM_ORDER = ["douyin", "kuaishou", "shipinhao"];
+
+function platformsSorted(runs: RunListItem[]): string[] {
+  const unique = [...new Set(runs.map((run) => run.platform || "unknown"))];
+  return unique.sort((a, b) => {
+    const ia = PLATFORM_ORDER.indexOf(a);
+    const ib = PLATFORM_ORDER.indexOf(b);
+    return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
+  });
+}
+
+function byStartedDesc(a: RunListItem, b: RunListItem): number {
+  return (b.started_at || "") > (a.started_at || "") ? 1 : (b.started_at || "") < (a.started_at || "") ? -1 : 0;
+}
+
+function buildDemands(runs: RunListItem[]): DemandGroup[] {
+  const map = new Map<string, DemandGroup>();
+  for (const run of runs) {
+    const id = run.demand_content_id ?? null;
+    const key = id != null ? `id:${id}` : "unknown";
+    const name = run.demand_name || (id != null ? `需求 ${id}` : "未知需求");
+    let group = map.get(key);
+    if (!group) {
+      group = { key, id, name, runs: [], latest: "" };
+      map.set(key, group);
+    }
+    group.runs.push(run);
+    if ((run.started_at || "") > group.latest) group.latest = run.started_at || "";
+  }
+  return [...map.values()].sort((a, b) => (b.latest > a.latest ? 1 : b.latest < a.latest ? -1 : 0));
+}
+
 export function RunListPage() {
 export function RunListPage() {
   const [runs, setRuns] = useState<RunListItem[]>([]);
   const [runs, setRuns] = useState<RunListItem[]>([]);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
+  const [query, setQuery] = useState("");
+  const [selectedKey, setSelectedKey] = useState<string | null>(null);
 
 
   async function load() {
   async function load() {
     setLoading(true);
     setLoading(true);
     setError(null);
     setError(null);
     try {
     try {
-      const data = await listRuns(new URLSearchParams({ page_size: "50" }));
+      // 现规模(几十条 run)一次取够,前端按需求分组;>100 再加分页。
+      const data = await listRuns(new URLSearchParams({ page_size: "100" }));
       setRuns(data.items || []);
       setRuns(data.items || []);
     } catch (err) {
     } catch (err) {
       setError(err instanceof Error ? err.message : String(err));
       setError(err instanceof Error ? err.message : String(err));
@@ -67,29 +110,97 @@ export function RunListPage() {
     void load();
     void load();
   }, []);
   }, []);
 
 
+  const demands = useMemo(() => buildDemands(runs), [runs]);
+  const filtered = useMemo(() => {
+    const q = query.trim().toLowerCase();
+    if (!q) return demands;
+    return demands.filter((d) => d.name.toLowerCase().includes(q) || (d.id != null && String(d.id).includes(q)));
+  }, [demands, query]);
+
+  useEffect(() => {
+    if (filtered.length && !filtered.some((d) => d.key === selectedKey)) {
+      setSelectedKey(filtered[0].key);
+    }
+  }, [filtered, selectedKey]);
+
+  const selected = demands.find((d) => d.key === selectedKey) || null;
+
   return (
   return (
-    <AppShell title="运营流程账本" subtitle="选择一次内容发现任务,查看搜索词如何一路产生内容" onRefresh={load}>
-      <section className="source-block open">
-        <div className="source-summary">选择一次任务,进入完整流程账本。</div>
-      </section>
+    <AppShell title="按需求查看运行" subtitle="左侧选需求,右侧看它在各渠道的每一次运行" onRefresh={load}>
       {loading ? <LoadingState /> : null}
       {loading ? <LoadingState /> : null}
       {error ? <ErrorState message={error} /> : null}
       {error ? <ErrorState message={error} /> : null}
       {!loading && !error && !runs.length ? <EmptyState label="暂无任务记录" /> : null}
       {!loading && !error && !runs.length ? <EmptyState label="暂无任务记录" /> : null}
-      <section className="run-list">
-        {runs.map((run) => (
-          <Link className="run-row" href={`/runs/${encodeURIComponent(run.run_id)}`} key={run.run_id}>
-            <div>
-              <strong>{run.demand_name || run.run_id}</strong>
-              <span>{run.demand_desc || `任务编号 ${run.run_id}`}</span>
+      {runs.length ? (
+        <div className="runs-by-demand">
+          <aside className="rbd-demands">
+            <input
+              className="rbd-search"
+              type="search"
+              placeholder="搜索需求(名称 / ID)"
+              value={query}
+              onChange={(e) => setQuery(e.target.value)}
+            />
+            <div className="rbd-demand-list">
+              {filtered.map((demand) => (
+                <button
+                  key={demand.key}
+                  type="button"
+                  className={`rbd-demand ${selected?.key === demand.key ? "active" : ""}`}
+                  onClick={() => setSelectedKey(demand.key)}
+                >
+                  <strong>{demand.name}</strong>
+                  <span className="rbd-demand-meta">
+                    {demand.id != null ? `#${demand.id} · ` : ""}{demand.runs.length} 次运行
+                  </span>
+                  <span className="rbd-demand-platforms">
+                    {platformsSorted(demand.runs).map((p) => (
+                      <span className="chip" key={p}>{platformLabel(p)}</span>
+                    ))}
+                  </span>
+                  <span className="rbd-demand-time">最新 {formatBeijingTime(demand.latest)}</span>
+                </button>
+              ))}
+              {filtered.length === 0 ? <span className="muted">没有匹配的需求</span> : null}
             </div>
             </div>
-            <div className="run-row-meta">
-              <span className="chip">{platformLabel(run.platform)}</span>
-              <span className={`chip ${statusChipClass(run.status)}`}>{statusLabel(run.status)}</span>
-              <span>{formatBeijingTime(run.started_at)}</span>
-            </div>
-          </Link>
-        ))}
-      </section>
+          </aside>
+
+          <section className="rbd-runs">
+            {selected ? (
+              <>
+                <div className="rbd-runs-head">
+                  <strong>{selected.name}</strong>
+                  <span className="muted">
+                    {selected.id != null ? `需求 #${selected.id} · ` : ""}
+                    {selected.runs.length} 次运行 · {platformsSorted(selected.runs).length} 个渠道
+                  </span>
+                </div>
+                {platformsSorted(selected.runs).map((p) => {
+                  const platformRuns = selected.runs.filter((r) => (r.platform || "unknown") === p).sort(byStartedDesc);
+                  return (
+                    <div className="rbd-platform" key={p}>
+                      <div className="rbd-platform-head">
+                        <span className="chip blue">{platformLabel(p)}</span>
+                        <span className="muted">{platformRuns.length} 次运行</span>
+                      </div>
+                      <div className="rbd-run-list">
+                        {platformRuns.map((run) => (
+                          <Link className="rbd-run" href={`/runs/${encodeURIComponent(run.run_id)}`} key={run.run_id}>
+                            <span className="rbd-run-time">{formatBeijingTime(run.started_at)}</span>
+                            <span className={`chip ${statusChipClass(run.status)}`}>{statusLabel(run.status)}</span>
+                            <span className="rbd-run-id muted">{run.run_id}</span>
+                          </Link>
+                        ))}
+                      </div>
+                    </div>
+                  );
+                })}
+              </>
+            ) : (
+              <EmptyState label="选择左侧需求查看运行" />
+            )}
+          </section>
+        </div>
+      ) : null}
     </AppShell>
     </AppShell>
   );
   );
 }
 }

+ 1 - 0
web2/lib/api/types.ts

@@ -11,6 +11,7 @@ export type RunListItem = {
   platform_mode?: string | null;
   platform_mode?: string | null;
   content_format?: string | null;
   content_format?: string | null;
   strategy_version?: string | null;
   strategy_version?: string | null;
+  demand_content_id?: number | null;
   demand_name?: string | null;
   demand_name?: string | null;
   demand_desc?: string | null;
   demand_desc?: string | null;
   validation_status?: string | null;
   validation_status?: string | null;