Procházet zdrojové kódy

feat(web-v3): 顶部两层(漏斗总览条 7 数字 + 5 面板导航)(合一批2)

- FunnelStrip:7 段漏斗(需求→Query→内容→判定→游走→资产→学习),点 Query/内容/判定 跳发现旅程
- PanelNav:5 tab(需求/发现旅程/内容资产/学习复盘/策略配置),发现旅程卡副行挂 Query/内容/判定 计数
- 默认落地面板=发现旅程;复用 stage_conclusions/stageCount 零新增取数

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee před 2 měsíci
rodič
revize
b7c3943be5
2 změnil soubory, kde provedl 112 přidání a 28 odebrání
  1. 35 0
      web/app/globals.css
  2. 77 28
      web/features/runs/RunDashboardPage.tsx

+ 35 - 0
web/app/globals.css

@@ -1668,3 +1668,38 @@ a {
   background: #f1f4f9; border-radius: 6px; width: fit-content;
 }
 .wj-page-group > .wj-vid > .wj-vid-head { background: #f7f9fc; border-color: #e4e8f0; }
+
+/* ===== 激进合一:漏斗总览条 + 5 面板导航 ===== */
+.funnel-strip {
+  display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
+  padding: 10px 14px; margin-bottom: 10px;
+  border: 1px solid #dfe5ee; border-radius: 10px; background: #ffffff;
+}
+.funnel-seg-wrap { display: inline-flex; align-items: center; }
+.funnel-arrow { color: #c2cbd9; margin: 0 2px; }
+.funnel-seg {
+  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
+  padding: 5px 10px; border: 1px solid transparent; border-radius: 8px;
+  background: transparent; cursor: pointer; min-width: 64px;
+}
+.funnel-seg:hover { background: #f4f7fb; border-color: #e0e6f0; }
+.funnel-label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: #5d6b82; font-weight: 600; white-space: nowrap; }
+.funnel-count { font-size: 18px; font-weight: 900; color: #172033; }
+.funnel-dot { width: 7px; height: 7px; border-radius: 50%; background: #b4bccb; }
+.funnel-dot.success { background: #2e9b5b; }
+.funnel-dot.failed, .funnel-dot.rule_blocked { background: #dc4040; }
+.funnel-dot.pending, .funnel-dot.partial_success { background: #e0a020; }
+
+.panel-nav {
+  display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-bottom: 10px;
+}
+.panel-nav-card {
+  display: flex; flex-direction: column; gap: 4px; min-height: 58px;
+  padding: 11px 13px; text-align: left; cursor: pointer;
+  border: 1px solid #dfe5ee; border-radius: 9px; background: #ffffff;
+}
+.panel-nav-card:hover { border-color: #b9c4d6; background: #fafbfd; }
+.panel-nav-card.active { border-color: #2360ad; background: #eaf3ff; box-shadow: inset 0 0 0 1px #d8e8ff; }
+.panel-nav-top strong { font-size: 14px; font-weight: 900; color: #172033; }
+.panel-nav-card.active .panel-nav-top strong { color: #2360ad; }
+.panel-nav-sub { font-size: 11.5px; color: #8491a6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

+ 77 - 28
web/features/runs/RunDashboardPage.tsx

@@ -66,7 +66,7 @@ async function optionalRuntimeFile(runId: string, filename: string): Promise<Run
 }
 
 export function RunDashboardPage({ runId }: { runId: string }) {
-  const [activeStage, setActiveStage] = useState("source");
+  const [activeStage, setActiveStage] = useState("walk");
   const [data, setData] = useState<DashboardData | null>(null);
   const [runtimeFile, setRuntimeFile] = useState<RuntimeFileResponse | null>(null);
   const [drawer, setDrawer] = useState<DrawerContent>(null);
@@ -141,12 +141,8 @@ export function RunDashboardPage({ runId }: { runId: string }) {
 
       {data ? (
         <section className="detail-panel business-page">
-          <StageNavigationCards
-            dashboard={data.dashboard}
-            stages={data.dashboard.stage_conclusions}
-            activeStage={activeStage}
-            onSelect={setActiveStage}
-          />
+          <FunnelStrip dashboard={data.dashboard} stages={data.dashboard.stage_conclusions} onSelect={setActiveStage} />
+          <PanelNav dashboard={data.dashboard} stages={data.dashboard.stage_conclusions} activeStage={activeStage} onSelect={setActiveStage} />
           <TimelineSummaryStrip runId={runId} timeline={data.timeline} contentItems={data.contentItems} />
           <StagePanel
             activeStage={activeStage}
@@ -211,7 +207,53 @@ function TimelineSummaryStrip({
   );
 }
 
-function StageNavigationCards({
+// 激进合一:被折叠的 query/platform/judge 漏斗段点击后跳「发现旅程」。
+function funnelTarget(stageId: string): string {
+  if (stageId === "query" || stageId === "platform" || stageId === "judge") return "walk";
+  return stageId;
+}
+
+// 第一层:漏斗总览条(7 段,只读 + 点击跳对应面板)。复用 stage_conclusions 与 stageCount,零新增取数。
+function FunnelStrip({
+  dashboard,
+  stages,
+  onSelect
+}: {
+  dashboard: DashboardResponse;
+  stages: StageConclusion[];
+  onSelect: (stageId: string) => void;
+}) {
+  return (
+    <div className="funnel-strip" aria-label="运行漏斗总览">
+      {stages.map((stage, i) => {
+        const count = stageCount(dashboard, stage.stage_id);
+        return (
+          <span className="funnel-seg-wrap" key={stage.stage_id}>
+            {i > 0 ? <ChevronRight size={14} className="funnel-arrow" /> : null}
+            <button type="button" className={`funnel-seg ${stage.status}`} onClick={() => onSelect(funnelTarget(stage.stage_id))}>
+              <span className="funnel-label">
+                <span className={`funnel-dot ${stage.status}`} />
+                {stage.label}
+              </span>
+              <strong className="funnel-count">{count !== null ? count : "—"}</strong>
+            </button>
+          </span>
+        );
+      })}
+    </div>
+  );
+}
+
+// 第二层:5 面板导航(前端常量驱动)。发现旅程卡副行挂 Query/内容/判定 三计数。
+const PANELS: Array<{ id: string; label: string }> = [
+  { id: "source", label: "需求" },
+  { id: "walk", label: "发现旅程" },
+  { id: "asset", label: "内容资产" },
+  { id: "learning", label: "学习复盘" },
+  { id: "config", label: "策略配置" }
+];
+
+function PanelNav({
   dashboard,
   stages,
   activeStage,
@@ -222,27 +264,34 @@ function StageNavigationCards({
   activeStage: string;
   onSelect: (stageId: string) => void;
 }) {
+  const statusOf = (id: string) => stages.find((s) => s.stage_id === id)?.status || "";
+  const headlineOf = (id: string) => stages.find((s) => s.stage_id === id)?.headline || "";
   return (
-    <nav className="stage-nav-grid" aria-label="运行阶段">
-      {stages.map((stage) => (
-        <button
-          className={`stage-nav-card ${stage.status} ${activeStage === stage.stage_id ? "active" : ""}`}
-          key={stage.stage_id}
-          onClick={() => onSelect(stage.stage_id)}
-          type="button"
-        >
-          <div className="stage-nav-top">
-            <strong>{stage.label}</strong>
-            {stageCount(dashboard, stage.stage_id) !== null ? (
-              <span className="stage-nav-count">{stageCount(dashboard, stage.stage_id)}</span>
-            ) : null}
-          </div>
-          <div className="stage-nav-body">
-            <span>{stage.headline}</span>
-            <small>{stage.metric}</small>
-          </div>
-        </button>
-      ))}
+    <nav className="panel-nav" aria-label="工作台面板">
+      {PANELS.map((p) => {
+        const active = activeStage === p.id;
+        return (
+          <button
+            type="button"
+            key={p.id}
+            className={`panel-nav-card ${statusOf(p.id)} ${active ? "active" : ""}`}
+            onClick={() => onSelect(p.id)}
+          >
+            <div className="panel-nav-top">
+              <strong>{p.label}</strong>
+            </div>
+            {p.id === "walk" ? (
+              <div className="panel-nav-sub">
+                Query {stageCount(dashboard, "query")} · 内容 {stageCount(dashboard, "platform")} · 判定 {stageCount(dashboard, "judge")}
+              </div>
+            ) : p.id === "config" ? (
+              <div className="panel-nav-sub">当前生效的全局策略配置</div>
+            ) : (
+              <div className="panel-nav-sub">{headlineOf(p.id)}</div>
+            )}
+          </button>
+        );
+      })}
     </nav>
   );
 }