RunListPage.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. "use client";
  2. import { Search } from "lucide-react";
  3. import { useCallback, useEffect, useMemo, useState } from "react";
  4. import { AppShell } from "@/components/layout/AppShell";
  5. import { DataOriginBadge, StatusBadge } from "@/components/badges/StatusBadge";
  6. import { listRuns } from "@/lib/api/client";
  7. import { platformLabel } from "@/lib/platform/content";
  8. import { statusLabel } from "@/lib/status/status";
  9. import { DemandDetail } from "@/features/runs/DemandDetail";
  10. import type { RunListItem, RunListResponse } from "@/lib/api/types";
  11. // run 级状态仅此 4 种(RunState.status 合同);pending/rule_blocked 是内容/决策级状态,不在此列。
  12. const statusOptions = ["", "success", "partial_success", "failed", "running"];
  13. export function RunListPage() {
  14. const [status, setStatus] = useState("");
  15. const [search, setSearch] = useState("");
  16. const [data, setData] = useState<RunListResponse | null>(null);
  17. const [error, setError] = useState<string | null>(null);
  18. const [loading, setLoading] = useState(true);
  19. const [selectedId, setSelectedId] = useState<string | null>(null);
  20. const load = useCallback(async () => {
  21. setLoading(true);
  22. setError(null);
  23. const params = new URLSearchParams({ page_size: "50" });
  24. if (status) {
  25. params.set("status", status);
  26. }
  27. try {
  28. setData(await listRuns(params));
  29. } catch (err) {
  30. setError(err instanceof Error ? err.message : String(err));
  31. } finally {
  32. setLoading(false);
  33. }
  34. }, [status]);
  35. useEffect(() => {
  36. void load();
  37. }, [load]);
  38. const items = useMemo(() => {
  39. let source = data?.items || [];
  40. const keyword = search.trim().toLowerCase();
  41. if (keyword) {
  42. source = source.filter((item) =>
  43. [item.run_id, item.policy_run_id, item.error_code, item.platform_mode]
  44. .filter(Boolean)
  45. .some((value) => String(value).toLowerCase().includes(keyword))
  46. );
  47. }
  48. // 按运行时间倒序(最新在最上);无时间的(如冒烟测试)沉到最底。
  49. return [...source].sort((a, b) => {
  50. const ta = a.started_at ? Date.parse(a.started_at) : NaN;
  51. const tb = b.started_at ? Date.parse(b.started_at) : NaN;
  52. const va = Number.isNaN(ta) ? -Infinity : ta;
  53. const vb = Number.isNaN(tb) ? -Infinity : tb;
  54. return vb - va;
  55. });
  56. }, [data, search]);
  57. // 默认选中第一条(最新);当前选中项被筛掉时回退到第一条。
  58. useEffect(() => {
  59. if (!items.length) {
  60. setSelectedId(null);
  61. return;
  62. }
  63. if (!selectedId || !items.some((it) => it.run_id === selectedId)) {
  64. setSelectedId(items[0].run_id);
  65. }
  66. }, [items, selectedId]);
  67. const selected = items.find((it) => it.run_id === selectedId) || null;
  68. return (
  69. <AppShell onRefresh={load}>
  70. <section className="filter-bar">
  71. <div className="filter-title">
  72. <span>Run 列表</span>
  73. <strong>真实运行记录</strong>
  74. </div>
  75. <label className="field">
  76. <span>状态</span>
  77. <select className="select" value={status} onChange={(event) => setStatus(event.target.value)}>
  78. {statusOptions.map((option) => (
  79. <option key={option || "all"} value={option}>
  80. {option || "全部"}
  81. </option>
  82. ))}
  83. </select>
  84. </label>
  85. <label className="field">
  86. <span>搜索</span>
  87. <Search size={15} />
  88. <input
  89. className="input"
  90. value={search}
  91. onChange={(event) => setSearch(event.target.value)}
  92. placeholder="run_id / error_code"
  93. />
  94. </label>
  95. {data ? <DataOriginBadge origin={data.data_origin} /> : null}
  96. </section>
  97. <section className="main-grid">
  98. <div className="list-panel">
  99. {loading ? <div className="loading-state">加载中</div> : null}
  100. {error ? <div className="error-state">{error}</div> : null}
  101. {!loading && !error && !items.length ? <div className="empty-state">没有匹配的 run</div> : null}
  102. {!loading && !error
  103. ? items.map((item) => (
  104. <RunCard
  105. item={item}
  106. active={item.run_id === selectedId}
  107. onSelect={() => setSelectedId(item.run_id)}
  108. key={item.run_id}
  109. />
  110. ))
  111. : null}
  112. </div>
  113. <div className="detail-panel">
  114. {selected ? (
  115. <DemandDetail item={selected} key={selected.run_id} />
  116. ) : (
  117. <div className="empty-state">左侧选择一条需求查看详情。</div>
  118. )}
  119. </div>
  120. </section>
  121. </AppShell>
  122. );
  123. }
  124. // 内容形态文案;接口给英文枚举(video/image/...),列表统一显示中文。
  125. function contentFormatLabel(value: unknown): string {
  126. const map: Record<string, string> = { video: "视频", image: "图文", note: "图文", live: "直播" };
  127. const key = String(value || "").toLowerCase();
  128. return map[key] || (key ? key : "");
  129. }
  130. // ISO 时间 → 「YYYY-MM-DD HH:mm」(本地时区);拿不到则空。
  131. function fmtRunTime(iso?: string | null): string {
  132. if (!iso) return "";
  133. const d = new Date(iso);
  134. if (Number.isNaN(d.getTime())) return "";
  135. const p = (n: number) => String(n).padStart(2, "0");
  136. return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
  137. }
  138. function RunCard({ item, active, onSelect }: { item: RunListItem; active: boolean; onSelect: () => void }) {
  139. // 数据源行:平台 · 内容形态 · 策略(用户选「显示数据源平台+格式」取代未落库的服务器字段)。
  140. const source = [
  141. item.platform ? platformLabel(item.platform) : "",
  142. contentFormatLabel(item.content_format),
  143. item.strategy_version ? `策略 ${item.strategy_version}` : ""
  144. ].filter(Boolean);
  145. const runTime = fmtRunTime(item.started_at);
  146. return (
  147. <button type="button" className={`run-card ${active ? "active" : ""}`} onClick={onSelect}>
  148. <div className="run-card-title">
  149. <span className="run-card-demand">{item.demand_name || item.run_id}</span>
  150. <StatusBadge status={item.status} />
  151. </div>
  152. <div className="run-card-meta">
  153. <span className="run-card-time">🕒 {runTime || "运行时间未知"}</span>
  154. {source.length ? <span>{source.join(" · ")}</span> : null}
  155. </div>
  156. <div className="run-card-meta">
  157. <span className="run-card-id">{item.run_id}</span>
  158. {item.validation_status ? <span>校验{statusLabel(item.validation_status)}</span> : null}
  159. {item.error_code ? <span className="run-card-err">{item.error_code}</span> : null}
  160. </div>
  161. </button>
  162. );
  163. }