Преглед изворни кода

feat: add web2 flow ledger frontend

Sam Lee пре 3 недеља
родитељ
комит
6434a07adf

+ 7 - 0
web2/.gitignore

@@ -0,0 +1,7 @@
+.next
+node_modules
+out
+dist
+.DS_Store
+*.log
+tsconfig.tsbuildinfo

+ 988 - 0
web2/app/globals.css

@@ -0,0 +1,988 @@
+:root {
+  --bg: #f5f5f7;
+  --surface: #ffffff;
+  --line: #e5e7eb;
+  --line-strong: #94a3b8;
+  --text: #111827;
+  --muted: #6b7280;
+  --faint: #94a3b8;
+  --source: #1e293b;
+  --source-sub: #475569;
+  --query: #b45309;
+  --query-sub: #d97706;
+  --impl: #065f46;
+  --impl-sub: #047857;
+  --output: #1e40af;
+  --output-sub: #2563eb;
+  font-family: -apple-system, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
+  color: var(--text);
+  background: var(--bg);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  margin: 0;
+  min-height: 100%;
+  background: var(--bg);
+}
+
+body {
+  height: 100vh;
+  overflow: hidden;
+  font-size: 13px;
+}
+
+button,
+input,
+select {
+  font: inherit;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+button,
+a {
+  touch-action: manipulation;
+}
+
+button:focus-visible,
+a:focus-visible {
+  outline: 3px solid #bfdbfe;
+  outline-offset: 2px;
+}
+
+.app-shell {
+  height: 100vh;
+  background: var(--bg);
+}
+
+.scroll-area {
+  height: 100%;
+  overflow: auto;
+}
+
+.page-header {
+  min-height: 52px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 14px;
+  padding: 12px 20px 8px;
+  background: var(--surface);
+  border-bottom: 1px solid var(--line);
+}
+
+.header-left,
+.toolbar,
+.brand,
+.toolbar-button,
+.icon-button,
+.mini-button {
+  display: inline-flex;
+  align-items: center;
+}
+
+.header-left {
+  gap: 10px;
+  min-width: 0;
+}
+
+.toolbar {
+  justify-content: flex-end;
+  gap: 8px;
+}
+
+.brand {
+  gap: 6px;
+  color: #111827;
+  font-size: 16px;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.header-subtitle {
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+}
+
+.toolbar-button,
+.icon-button,
+.mini-button {
+  min-height: 32px;
+  justify-content: center;
+  gap: 6px;
+  border: 1px solid #cbd5e1;
+  border-radius: 4px;
+  color: #334155;
+  background: #fff;
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 600;
+  transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
+}
+
+.toolbar-button {
+  padding: 0 10px;
+}
+
+.icon-button {
+  width: 34px;
+}
+
+.mini-button {
+  min-height: 28px;
+  padding: 0 8px;
+}
+
+.mini-button.ghost {
+  color: #64748b;
+  background: #f8fafc;
+}
+
+.toolbar-button:hover,
+.icon-button:hover,
+.mini-button:hover {
+  color: #1e40af;
+  border-color: #93c5fd;
+  background: #eff6ff;
+}
+
+.source-block,
+.declarations {
+  margin: 10px 20px 0;
+  background: #fff;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+}
+
+.source-block summary,
+.declarations summary {
+  padding: 9px 14px;
+  cursor: pointer;
+  list-style: none;
+}
+
+.source-block summary::-webkit-details-marker,
+.declarations summary::-webkit-details-marker {
+  display: none;
+}
+
+.source-block summary::before,
+.declarations summary::before {
+  content: ">";
+  display: inline-block;
+  margin-right: 7px;
+  color: var(--faint);
+  font-size: 10px;
+  transform: rotate(90deg);
+}
+
+.source-block:not([open]) summary::before,
+.declarations:not([open]) summary::before {
+  transform: rotate(0);
+}
+
+.source-summary,
+.source-body {
+  padding: 8px 14px;
+  color: #475569;
+  line-height: 1.6;
+}
+
+.source-body {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  border-top: 1px solid #e2e8f0;
+}
+
+.decl-purpose {
+  margin-left: 12px;
+  color: #475569;
+  font-size: 12px;
+}
+
+.tag-mini {
+  display: inline-block;
+  margin-left: 8px;
+  padding: 1px 7px;
+  border-radius: 3px;
+  color: #92400e;
+  background: #fef3c7;
+  font-size: 11px;
+  font-weight: 600;
+}
+
+.kw {
+  color: #6d28d9;
+  font-weight: 700;
+}
+
+.decl-body {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 14px 32px;
+  padding: 12px 18px 14px;
+  border-top: 1px solid #e2e8f0;
+}
+
+.decl-section {
+  min-width: 260px;
+  flex: 1 1 280px;
+}
+
+.decl-label {
+  margin-bottom: 6px;
+  padding-bottom: 4px;
+  border-bottom: 1px dashed #e2e8f0;
+  color: #475569;
+  font-size: 11.5px;
+  font-weight: 700;
+}
+
+.decl-row {
+  color: #1f2937;
+  font-size: 12.5px;
+  line-height: 1.65;
+}
+
+.legend {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px 12px;
+  margin-top: 12px;
+  padding: 10px 20px;
+  background: #fff;
+  border-top: 1px solid var(--line);
+  border-bottom: 1px solid var(--line);
+  color: var(--muted);
+  font-size: 11.5px;
+}
+
+.legend-group {
+  display: inline-flex;
+}
+
+.legend-head {
+  padding: 2px 8px;
+  border: 0;
+  border-radius: 3px;
+  color: #1f2937;
+  cursor: pointer;
+  font-weight: 700;
+}
+
+.legend-group.gray .legend-head {
+  background: #e2e8f0;
+}
+
+.legend-group.yellow .legend-head {
+  background: #fde68a;
+}
+
+.legend-group.green .legend-head {
+  background: #bbf7d0;
+}
+
+.legend-group.blue .legend-head {
+  background: #bfdbfe;
+}
+
+.legend-head.off {
+  opacity: 0.45;
+  text-decoration: line-through;
+}
+
+.legend-hint {
+  margin-left: auto;
+  color: var(--faint);
+  font-size: 11px;
+}
+
+.table-wrap {
+  background: #fff;
+}
+
+.ledger-table {
+  width: max-content;
+  min-width: 100%;
+  border-collapse: collapse;
+  background: #fff;
+  table-layout: auto;
+  font-size: 12px;
+}
+
+.ledger-table th,
+.ledger-table td {
+  border: 1px solid var(--line);
+  padding: 6px 8px;
+  vertical-align: top;
+  line-height: 1.5;
+}
+
+.ledger-table thead th {
+  position: sticky;
+  top: 0;
+  z-index: 4;
+  padding: 8px 6px;
+  color: #fff;
+  font-size: 12.5px;
+  font-weight: 700;
+  text-align: center;
+}
+
+.ledger-table thead tr:nth-child(2) th {
+  top: 33px;
+}
+
+.ledger-table tbody td {
+  font-size: 12px;
+}
+
+.ledger-row > td {
+  border-top: 1.5px solid var(--line-strong);
+}
+
+.group-source,
+.sub-source {
+  background: var(--source) !important;
+}
+
+.group-query,
+.sub-query {
+  background: var(--query) !important;
+}
+
+.group-videos,
+.group-rules,
+.sub-videos,
+.sub-rules {
+  background: var(--impl) !important;
+}
+
+.group-walk,
+.group-assets,
+.sub-walk,
+.sub-assets {
+  background: var(--output) !important;
+}
+
+.sub-source {
+  background: var(--source-sub) !important;
+}
+
+.sub-query {
+  background: var(--query-sub) !important;
+}
+
+.sub-videos,
+.sub-rules {
+  background: var(--impl-sub) !important;
+}
+
+.sub-walk,
+.sub-assets {
+  background: var(--output-sub) !important;
+}
+
+.source-cell,
+.query-cell {
+  background: #f8fafc;
+}
+
+.videos-cell,
+.rules-cell {
+  background: #f0fdf4;
+}
+
+.walk-cell,
+.asset-cell {
+  background: #eff6ff;
+}
+
+.source-cell,
+.sub-source,
+.group-source {
+  width: 278px;
+  min-width: 278px;
+  max-width: 278px;
+}
+
+.query-cell,
+.sub-query,
+.group-query {
+  width: 260px;
+  min-width: 260px;
+  max-width: 260px;
+}
+
+.videos-cell {
+  min-width: 360px;
+  max-width: 390px;
+}
+
+.rules-cell {
+  min-width: 190px;
+  max-width: 260px;
+}
+
+.walk-cell {
+  min-width: 210px;
+  max-width: 240px;
+}
+
+.asset-cell {
+  min-width: 130px;
+}
+
+.sticky-source {
+  position: sticky !important;
+  left: 0;
+  z-index: 3;
+}
+
+.sticky-query {
+  position: sticky !important;
+  left: 278px;
+  z-index: 3;
+}
+
+.ledger-table thead .sticky-source,
+.ledger-table thead .sticky-query {
+  z-index: 6;
+}
+
+.ledger-table.source-hidden .sticky-query {
+  left: 0;
+}
+
+.query-cell,
+.sub-query,
+.group-query {
+  box-shadow: 6px 0 8px -4px rgba(0, 0, 0, 0.18);
+}
+
+.source-hidden .query-cell,
+.source-hidden .sub-query,
+.source-hidden .group-query {
+  box-shadow: 6px 0 8px -4px rgba(0, 0, 0, 0.18);
+}
+
+.idx {
+  display: inline-block;
+  margin-bottom: 5px;
+  padding: 0 5px;
+  border-radius: 3px;
+  color: #475569;
+  background: #e2e8f0;
+  font-family: ui-monospace, "SF Mono", monospace;
+  font-size: 10px;
+}
+
+.cell-stack {
+  display: grid;
+  gap: 5px;
+  min-width: 0;
+}
+
+.query-text,
+.title-link,
+.cell-stack strong {
+  color: #0f172a;
+  overflow-wrap: anywhere;
+}
+
+.title-link:hover {
+  color: #1e40af;
+  text-decoration: underline;
+}
+
+.muted {
+  color: var(--muted);
+  font-size: 11.5px;
+  overflow-wrap: anywhere;
+}
+
+.strong-muted {
+  color: #334155;
+  font-size: 12px;
+  font-weight: 650;
+  line-height: 1.55;
+}
+
+.chip-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.chip {
+  display: inline-block;
+  width: fit-content;
+  margin: 1px 3px 1px 0;
+  padding: 1px 7px;
+  border-radius: 10px;
+  color: #1f2937;
+  background: #d1d5db;
+  font-size: 11.5px;
+  line-height: 1.45;
+  white-space: nowrap;
+}
+
+.chip.dark {
+  color: #fff;
+  background: #475569;
+}
+
+.chip.yellow {
+  color: #78350f;
+  background: #fde68a;
+}
+
+.chip.green {
+  color: #065f46;
+  background: #bbf7d0;
+}
+
+.chip.blue {
+  color: #1e3a8a;
+  background: #bfdbfe;
+}
+
+.video-strip {
+  display: flex;
+  gap: 6px;
+  min-height: 56px;
+}
+
+.video-mini {
+  width: 108px;
+  min-height: 56px;
+  display: grid;
+  grid-template-columns: 34px 1fr;
+  gap: 6px;
+  padding: 5px;
+  border: 1px solid #d1fae5;
+  border-radius: 4px;
+  background: #fff;
+}
+
+.video-mini:hover {
+  border-color: #86efac;
+  background: #f7fee7;
+}
+
+.video-thumb {
+  width: 34px;
+  height: 46px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 3px;
+  color: #065f46;
+  background: #dcfce7;
+  font-size: 10px;
+  font-weight: 800;
+}
+
+.video-mini b {
+  display: -webkit-box;
+  overflow: hidden;
+  color: #111827;
+  font-size: 11.5px;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.video-mini small {
+  display: block;
+  margin-top: 2px;
+  color: var(--muted);
+  font-size: 10.5px;
+}
+
+.cell-footer {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin: 6px 0;
+  color: var(--muted);
+  font-size: 11.5px;
+}
+
+.run-list {
+  margin: 12px 20px 24px;
+  display: grid;
+  gap: 8px;
+}
+
+.run-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  min-height: 58px;
+  padding: 10px 12px;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.run-row:hover {
+  border-color: #bfdbfe;
+  background: #f8fafc;
+}
+
+.run-row strong,
+.run-row span {
+  display: block;
+}
+
+.run-row strong {
+  font-size: 13px;
+}
+
+.run-row span {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.run-row-meta {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 4px;
+}
+
+.state-block {
+  margin: 14px 20px;
+  padding: 18px;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  background: #fff;
+  color: #475569;
+}
+
+.error-state {
+  color: #991b1b;
+  background: #fef2f2;
+  border-color: #fecaca;
+}
+
+.detail-table-wrap {
+  margin: 12px 20px 24px;
+  overflow: auto;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.detail-table {
+  width: 100%;
+  min-width: 980px;
+  border-collapse: collapse;
+  font-size: 12px;
+}
+
+.detail-table th,
+.detail-table td {
+  border: 1px solid #e5e7eb;
+  padding: 7px 8px;
+  vertical-align: top;
+  line-height: 1.5;
+}
+
+.detail-table th {
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  color: #fff;
+  background: #475569;
+  font-weight: 700;
+  text-align: left;
+}
+
+.walk-lanes {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(210px, 1fr));
+  gap: 10px;
+  margin: 12px 20px;
+}
+
+.walk-lane {
+  min-height: 180px;
+  border: 1px solid #dbeafe;
+  border-radius: 6px;
+  background: #eff6ff;
+}
+
+.lane-head {
+  padding: 8px 10px;
+  color: #fff;
+  background: #1e40af;
+  border-radius: 5px 5px 0 0;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.walk-node {
+  width: calc(100% - 12px);
+  display: grid;
+  gap: 2px;
+  margin: 6px;
+  padding: 7px 8px;
+  border: 1px solid #bfdbfe;
+  border-radius: 4px;
+  color: #172554;
+  background: #fff;
+  cursor: pointer;
+  text-align: left;
+}
+
+.walk-node:hover {
+  border-color: #60a5fa;
+  background: #f8fbff;
+}
+
+.walk-node span,
+.walk-node small {
+  color: var(--muted);
+  overflow-wrap: anywhere;
+}
+
+.lane-empty {
+  display: block;
+  padding: 10px;
+}
+
+.video-detail-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(280px, 1fr));
+  gap: 12px;
+  margin: 12px 20px 24px;
+}
+
+.video-panel {
+  padding: 14px;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.video-panel h2 {
+  margin: 0 0 8px;
+  color: #111827;
+  font-size: 14px;
+}
+
+.video-panel p {
+  color: #334155;
+  line-height: 1.6;
+}
+
+.drawer-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 100;
+  display: none;
+  border: 0;
+  background: rgba(0, 0, 0, 0.35);
+}
+
+.drawer-overlay.open {
+  display: block;
+}
+
+.drawer {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 101;
+  width: 560px;
+  max-width: 92vw;
+  overflow-y: auto;
+  background: #fff;
+  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15);
+  transform: translateX(100%);
+  transition: transform 200ms ease-out;
+}
+
+.drawer.open {
+  transform: translateX(0);
+}
+
+.drawer header {
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  padding: 14px 18px;
+  color: #fff;
+  background: #1e293b;
+}
+
+.drawer h2 {
+  margin: 0;
+  padding-right: 32px;
+  font-size: 14px;
+}
+
+.drawer-close {
+  position: absolute;
+  top: 10px;
+  right: 12px;
+  width: 32px;
+  height: 32px;
+  border: 0;
+  color: #fff;
+  background: transparent;
+  cursor: pointer;
+}
+
+.drawer-content {
+  padding: 14px 18px;
+}
+
+.business-drawer-content {
+  display: grid;
+  gap: 10px;
+}
+
+.drawer-section {
+  padding: 12px 12px 10px;
+  border: 1px solid #e5e7eb;
+  border-left: 4px solid #94a3b8;
+  border-radius: 5px;
+  background: #fff;
+}
+
+.drawer-section.good {
+  border-left-color: #059669;
+}
+
+.drawer-section.warn {
+  border-left-color: #d97706;
+}
+
+.drawer-section.bad {
+  border-left-color: #dc2626;
+}
+
+.drawer-section.info {
+  border-left-color: #2563eb;
+}
+
+.drawer-section h3 {
+  margin: 0 0 6px;
+  color: #111827;
+  font-size: 12.5px;
+}
+
+.drawer-section p,
+.drawer-section li {
+  color: #334155;
+  font-size: 12.5px;
+  line-height: 1.65;
+}
+
+.drawer-section p {
+  margin: 0;
+}
+
+.drawer-section ul {
+  margin: 0;
+  padding-left: 16px;
+}
+
+@media (max-width: 760px) {
+  body {
+    overflow: auto;
+  }
+
+  .app-shell,
+  .scroll-area {
+    height: auto;
+    min-height: 100dvh;
+  }
+
+  .page-header {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .toolbar-button,
+  .icon-button,
+  .mini-button {
+    min-height: 44px;
+  }
+
+  .icon-button {
+    width: 44px;
+  }
+
+  .legend-hint {
+    width: 100%;
+    margin-left: 0;
+  }
+
+  .source-cell,
+  .sub-source,
+  .group-source {
+    width: 220px;
+    min-width: 220px;
+    max-width: 220px;
+  }
+
+  .query-cell,
+  .sub-query,
+  .group-query {
+    width: 220px;
+    min-width: 220px;
+    max-width: 220px;
+  }
+
+  .sticky-query {
+    left: 220px;
+  }
+
+  .sticky-source,
+  .sticky-query {
+    position: static !important;
+    left: auto;
+  }
+
+  .query-cell,
+  .sub-query,
+  .group-query,
+  .source-hidden .query-cell,
+  .source-hidden .sub-query,
+  .source-hidden .group-query {
+    box-shadow: none;
+  }
+
+  .walk-lanes,
+  .video-detail-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    transition-duration: 0.01ms !important;
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+  }
+}

+ 19 - 0
web2/app/layout.tsx

@@ -0,0 +1,19 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "运营流程账本",
+  description: "面向运营人员的内容发现流程账本"
+};
+
+export default function RootLayout({
+  children
+}: Readonly<{
+  children: React.ReactNode;
+}>) {
+  return (
+    <html lang="zh-CN">
+      <body>{children}</body>
+    </html>
+  );
+}

+ 5 - 0
web2/app/page.tsx

@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function HomePage() {
+  redirect("/runs");
+}

+ 10 - 0
web2/app/runs/[runId]/page.tsx

@@ -0,0 +1,10 @@
+import { LedgerPage } from "@/features/LedgerPage";
+
+type Props = {
+  params: Promise<{ runId: string }>;
+};
+
+export default async function RunLedgerRoute({ params }: Props) {
+  const { runId } = await params;
+  return <LedgerPage runId={runId} />;
+}

+ 10 - 0
web2/app/runs/[runId]/queries/[queryId]/videos/page.tsx

@@ -0,0 +1,10 @@
+import { QueryVideosPage } from "@/features/QueryVideosPage";
+
+type Props = {
+  params: Promise<{ runId: string; queryId: string }>;
+};
+
+export default async function QueryVideosRoute({ params }: Props) {
+  const { runId, queryId } = await params;
+  return <QueryVideosPage runId={runId} queryId={queryId} />;
+}

+ 10 - 0
web2/app/runs/[runId]/queries/[queryId]/walk/page.tsx

@@ -0,0 +1,10 @@
+import { QueryWalkPage } from "@/features/QueryWalkPage";
+
+type Props = {
+  params: Promise<{ runId: string; queryId: string }>;
+};
+
+export default async function QueryWalkRoute({ params }: Props) {
+  const { runId, queryId } = await params;
+  return <QueryWalkPage runId={runId} queryId={queryId} />;
+}

+ 10 - 0
web2/app/runs/[runId]/videos/[contentId]/page.tsx

@@ -0,0 +1,10 @@
+import { VideoDetailPage } from "@/features/VideoDetailPage";
+
+type Props = {
+  params: Promise<{ runId: string; contentId: string }>;
+};
+
+export default async function VideoDetailRoute({ params }: Props) {
+  const { runId, contentId } = await params;
+  return <VideoDetailPage runId={runId} contentId={contentId} />;
+}

+ 5 - 0
web2/app/runs/page.tsx

@@ -0,0 +1,5 @@
+import { RunListPage } from "@/features/RunListPage";
+
+export default function RunsPage() {
+  return <RunListPage />;
+}

+ 53 - 0
web2/components/AppShell.tsx

@@ -0,0 +1,53 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowLeft, Database, RefreshCw } from "lucide-react";
+
+export function AppShell({
+  children,
+  title = "运营流程账本",
+  subtitle,
+  runId,
+  showBack,
+  onRefresh,
+  actions
+}: {
+  children: React.ReactNode;
+  title?: string;
+  subtitle?: string;
+  runId?: string;
+  showBack?: boolean;
+  onRefresh?: () => void;
+  actions?: React.ReactNode;
+}) {
+  return (
+    <main className="app-shell">
+      <div className="scroll-area">
+        <header className="page-header">
+          <div className="header-left">
+            {showBack ? (
+              <Link className="toolbar-button" href={runId ? `/runs/${encodeURIComponent(runId)}` : "/runs"}>
+                <ArrowLeft size={15} />
+                返回
+              </Link>
+            ) : null}
+            <Link className="brand" href="/runs">
+              <Database size={16} />
+              <span>{title}</span>
+            </Link>
+            {subtitle ? <span className="header-subtitle">{subtitle}</span> : null}
+          </div>
+          <div className="toolbar">
+            {actions}
+            {onRefresh ? (
+              <button className="icon-button" type="button" title="刷新" aria-label="刷新" onClick={onRefresh}>
+                <RefreshCw size={15} />
+              </button>
+            ) : null}
+          </div>
+        </header>
+        {children}
+      </div>
+    </main>
+  );
+}

+ 45 - 0
web2/components/BusinessDrawer.tsx

@@ -0,0 +1,45 @@
+"use client";
+
+import { X } from "lucide-react";
+import type { BusinessSection } from "@/lib/flow-ledger/business";
+
+export type BusinessDrawerState = {
+  title: string;
+  sections: BusinessSection[];
+} | null;
+
+export function BusinessDrawer({ drawer, onClose }: { drawer: BusinessDrawerState; onClose: () => void }) {
+  return (
+    <>
+      <button
+        className={`drawer-overlay ${drawer ? "open" : ""}`}
+        type="button"
+        aria-label="关闭抽屉"
+        onClick={onClose}
+      />
+      <aside className={`drawer ${drawer ? "open" : ""}`} aria-hidden={!drawer}>
+        <header>
+          <h2>{drawer?.title || "详情"}</h2>
+          <button className="drawer-close" type="button" aria-label="关闭" onClick={onClose}>
+            <X size={17} />
+          </button>
+        </header>
+        <div className="drawer-content business-drawer-content">
+          {drawer?.sections.map((section) => (
+            <section className={`drawer-section ${section.tone || "neutral"}`} key={section.label}>
+              <h3>{section.label}</h3>
+              {section.value ? <p>{section.value}</p> : null}
+              {section.items?.length ? (
+                <ul>
+                  {section.items.map((item) => (
+                    <li key={item}>{item}</li>
+                  ))}
+                </ul>
+              ) : null}
+            </section>
+          ))}
+        </div>
+      </aside>
+    </>
+  );
+}

+ 11 - 0
web2/components/StateBlocks.tsx

@@ -0,0 +1,11 @@
+export function LoadingState({ label = "加载中" }: { label?: string }) {
+  return <div className="state-block loading-state">{label}</div>;
+}
+
+export function ErrorState({ message }: { message: string }) {
+  return <div className="state-block error-state">{message}</div>;
+}
+
+export function EmptyState({ label = "暂无数据" }: { label?: string }) {
+  return <div className="state-block empty-state">{label}</div>;
+}

+ 249 - 0
web2/features/LedgerPage.tsx

@@ -0,0 +1,249 @@
+"use client";
+
+import Link from "next/link";
+import { Eye, GitBranch, ListVideo } from "lucide-react";
+import { useMemo, useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
+import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import {
+  actionLabel,
+  assetSummaryText,
+  clueText,
+  methodLabel,
+  platformLabel,
+  ruleBrief,
+  rowDecisionSections,
+  ruleHeadline,
+  rulePrimaryReason,
+  sourceEvidence,
+  sourceLabel,
+  walkSummaryText
+} from "@/lib/flow-ledger/business";
+import type { FlowLedgerRow, VideoRef } from "@/lib/flow-ledger/types";
+import { useFlowLedger } from "./useFlowLedger";
+
+type GroupId = "source" | "query" | "videos" | "rules" | "walk" | "assets";
+
+const GROUPS: Array<{ id: GroupId; label: string; tone: string }> = [
+  { id: "source", label: "搜索词来源", tone: "gray" },
+  { id: "query", label: "搜索词", tone: "yellow" },
+  { id: "videos", label: "首轮搜到的视频", tone: "green" },
+  { id: "rules", label: "内容判断", tone: "green" },
+  { id: "walk", label: "继续扩展", tone: "blue" },
+  { id: "assets", label: "最终收获", tone: "blue" }
+];
+
+export function LedgerPage({ runId }: { runId: string }) {
+  const { ledger, input, loading, error, reload } = useFlowLedger(runId);
+  const [hidden, setHidden] = useState<Set<GroupId>>(new Set());
+  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
+
+  const rows = ledger?.rows || [];
+  const tableClass = [
+    hidden.has("source") ? "source-hidden" : "",
+    hidden.has("query") ? "query-hidden" : ""
+  ].filter(Boolean).join(" ");
+
+  function toggle(group: GroupId) {
+    setHidden((current) => {
+      const next = new Set(current);
+      if (next.has(group)) next.delete(group);
+      else next.add(group);
+      return next;
+    });
+  }
+
+  const headerSubtitle = useMemo(() => {
+    if (!input) return "查看搜索词如何一路产生内容";
+    const videoCount = rows.reduce((sum, row) => sum + row.firstRoundVideos.length, 0);
+    const assetCount = rows.reduce((sum, row) => sum + row.finalAssets.assetCount, 0);
+    return `本次共生成 ${rows.length} 个搜索词,首轮找到 ${videoCount} 条视频,沉淀 ${assetCount} 条内容`;
+  }, [input, rows]);
+
+  return (
+    <AppShell title="流程账本" subtitle={headerSubtitle} onRefresh={reload}>
+      {loading ? <LoadingState /> : null}
+      {error ? <ErrorState message={error} /> : null}
+      {!loading && !error && !rows.length ? <EmptyState label="没有可展示的搜索流程记录" /> : null}
+      {ledger ? (
+        <>
+          <details className="source-block" open>
+            <summary>本次流程摘要</summary>
+            <div className="source-body">
+              <span className="chip blue">搜索词 {rows.length}</span>
+              <span className="chip green">首轮视频 {rows.reduce((sum, row) => sum + row.firstRoundVideos.length, 0)}</span>
+              <span className="chip yellow">待复看 {rows.reduce((sum, row) => sum + row.ruleSummary.reviewCount, 0)}</span>
+              <span className="chip">已沉淀 {rows.reduce((sum, row) => sum + row.finalAssets.assetCount, 0)}</span>
+            </div>
+          </details>
+
+          <div className="legend">
+            {GROUPS.map((group) => (
+              <span className={`legend-group ${group.tone}`} key={group.id}>
+                <button className={`legend-head ${hidden.has(group.id) ? "off" : ""}`} type="button" onClick={() => toggle(group.id)}>
+                  {group.label}
+                </button>
+              </span>
+            ))}
+            <span className="legend-hint">点击组名显示/隐藏 · 左侧两列固定 · 判断原因在右侧说明里查看</span>
+          </div>
+
+          <section className="table-wrap">
+            <table className={`ledger-table ${tableClass}`}>
+              <thead>
+                <tr>
+                  {!hidden.has("source") ? <th className="group-source sticky-source">搜索词来源</th> : null}
+                  {!hidden.has("query") ? <th className="group-query sticky-query">搜索词</th> : null}
+                  {!hidden.has("videos") ? <th className="group-videos">首轮搜到的视频</th> : null}
+                  {!hidden.has("rules") ? <th className="group-rules" colSpan={2}>内容判断</th> : null}
+                  {!hidden.has("walk") ? <th className="group-walk">继续扩展</th> : null}
+                  {!hidden.has("assets") ? <th className="group-assets">最终收获</th> : null}
+                </tr>
+                <tr>
+                  {!hidden.has("source") ? <th className="sub-source sticky-source">从哪里来</th> : null}
+                  {!hidden.has("query") ? <th className="sub-query sticky-query">搜什么 / 怎么来</th> : null}
+                  {!hidden.has("videos") ? <th className="sub-videos">预览 / 统计 / 入口</th> : null}
+                  {!hidden.has("rules") ? <th className="sub-rules">判断结果</th> : null}
+                  {!hidden.has("rules") ? <th className="sub-rules">原因说明</th> : null}
+                  {!hidden.has("walk") ? <th className="sub-walk">有没有继续找</th> : null}
+                  {!hidden.has("assets") ? <th className="sub-assets">留下了什么</th> : null}
+                </tr>
+              </thead>
+              <tbody>
+                {rows.map((row, index) => (
+                  <tr className="ledger-row" key={row.id}>
+                    {!hidden.has("source") ? <SourceCell row={row} index={index} /> : null}
+                    {!hidden.has("query") ? <QueryCell row={row} /> : null}
+                    {!hidden.has("videos") ? <VideoCell runId={runId} row={row} /> : null}
+                    {!hidden.has("rules") ? <RuleSummaryCell row={row} /> : null}
+                    {!hidden.has("rules") ? (
+                      <td className="rules-cell">
+                        <div className="cell-stack">
+                          <span className="muted strong-muted">{ruleBrief(row)}</span>
+                          <button className="mini-button" type="button" onClick={() => setDrawer({ title: "为什么这样判断", sections: rowDecisionSections(row) })}>
+                            <Eye size={13} />
+                            看判断原因
+                          </button>
+                        </div>
+                      </td>
+                    ) : null}
+                    {!hidden.has("walk") ? <WalkCell runId={runId} row={row} /> : null}
+                    {!hidden.has("assets") ? <AssetCell row={row} /> : null}
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </section>
+        </>
+      ) : null}
+      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
+    </AppShell>
+  );
+}
+
+function SourceCell({ row, index }: { row: FlowLedgerRow; index: number }) {
+  return (
+    <td className="source-cell sticky-source">
+        <div className="idx">第 {index + 1} 条</div>
+      <div className="cell-stack">
+        <span className="chip dark">{sourceLabel(row.source.sourceKind)}</span>
+        <strong>{row.source.evidenceLabel}</strong>
+        <span className="muted">{sourceEvidence(row)}</span>
+      </div>
+    </td>
+  );
+}
+
+function QueryCell({ row }: { row: FlowLedgerRow }) {
+  const clue = clueText(row);
+  return (
+    <td className="query-cell sticky-query">
+      <div className="cell-stack">
+        <strong className="query-text">{row.query.text}</strong>
+        <div className="chip-row">
+          <span className="chip yellow">{methodLabel(row.query.method)}</span>
+        </div>
+        {clue ? <span className="muted">{clue}</span> : null}
+      </div>
+    </td>
+  );
+}
+
+function VideoCell({ runId, row }: { runId: string; row: FlowLedgerRow }) {
+  const top = row.firstRoundVideos.slice(0, 3);
+  return (
+    <td className="videos-cell">
+      <div className="video-strip">
+        {top.map((video) => (
+          <VideoMini runId={runId} video={video} key={video.id} />
+        ))}
+        {!top.length ? <span className="muted">首轮没有搜到可展示的视频</span> : null}
+      </div>
+      <div className="cell-footer">
+        <span>共 {row.firstRoundVideos.length} 条</span>
+        <span>入选 {row.ruleSummary.passCount}</span>
+        <span>淘汰 {row.ruleSummary.rejectCount}</span>
+        {row.duplicateCount ? <span>已合并重复 {row.duplicateCount}</span> : null}
+      </div>
+      <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(row.id)}/videos`}>
+        <ListVideo size={13} />
+        看全部视频
+      </Link>
+    </td>
+  );
+}
+
+function VideoMini({ runId, video }: { runId: string; video: VideoRef }) {
+  return (
+    <Link className="video-mini" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.id)}`}>
+      <span className="video-thumb">{platformLabel(video.platform)}</span>
+      <span>
+        <b>{video.title}</b>
+        <small>{actionLabel(video.decisionAction)}</small>
+      </span>
+    </Link>
+  );
+}
+
+function RuleSummaryCell({ row }: { row: FlowLedgerRow }) {
+  return (
+    <td className="rules-cell">
+      <div className="cell-stack">
+        <strong>{ruleHeadline(row)}</strong>
+        <div className="chip-row">
+          <span className="chip green">入选 {row.ruleSummary.passCount}</span>
+          <span className="chip yellow">待复看 {row.ruleSummary.reviewCount}</span>
+          <span className="chip">淘汰 {row.ruleSummary.rejectCount}</span>
+        </div>
+        <span className="muted">主要原因:{rulePrimaryReason(row)}</span>
+      </div>
+    </td>
+  );
+}
+
+function WalkCell({ runId, row }: { runId: string; row: FlowLedgerRow }) {
+  return (
+    <td className="walk-cell">
+      <div className="cell-stack">
+        <strong>{walkSummaryText(row)}</strong>
+        <span className="muted">{row.walkSummary.total ? "详情里可以看每一步为什么继续或停止" : "没有从首轮内容继续带出更多视频"}</span>
+        <Link className="mini-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(row.id)}/walk`}>
+          <GitBranch size={13} />
+          看扩展过程
+        </Link>
+      </div>
+    </td>
+  );
+}
+
+function AssetCell({ row }: { row: FlowLedgerRow }) {
+  return (
+    <td className="asset-cell">
+      <div className="cell-stack">
+        <strong>{assetSummaryText(row)}</strong>
+        <span className="muted">{row.finalAssets.assetCount ? "已进入后续内容池" : "这轮没有内容进入后续使用"}</span>
+      </div>
+    </td>
+  );
+}

+ 100 - 0
web2/features/QueryVideosPage.tsx

@@ -0,0 +1,100 @@
+"use client";
+
+import Link from "next/link";
+import { ExternalLink, PanelRightOpen } from "lucide-react";
+import { useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
+import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import { findLedgerRow } from "@/lib/flow-ledger/build";
+import { actionLabel, metricLabel, platformLabel, rowDecisionSections, sourceEvidence, videoDecisionSections, walkSummaryText } from "@/lib/flow-ledger/business";
+import { useFlowLedger } from "./useFlowLedger";
+
+export function QueryVideosPage({ runId, queryId }: { runId: string; queryId: string }) {
+  const { ledger, loading, error, reload } = useFlowLedger(runId);
+  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
+  const row = ledger ? findLedgerRow(ledger, queryId) : undefined;
+
+  return (
+    <AppShell title="首轮搜到的视频" subtitle={row ? `搜索词:${row.query.text}` : "查看这个搜索词搜到了哪些内容"} runId={runId} showBack onRefresh={reload}>
+      {loading ? <LoadingState /> : null}
+      {error ? <ErrorState message={error} /> : null}
+      {!loading && !error && !row ? <EmptyState label="没有找到这个搜索词的首轮视频" /> : null}
+      {row ? (
+        <>
+          <details className="declarations" open>
+            <summary>
+              <span className="kw">搜索词</span> <b>{row.query.text}</b>
+              <span className="decl-purpose">来源:{sourceEvidence(row)}</span>
+              <span className="tag-mini">首轮视频 {row.firstRoundVideos.length}</span>
+            </summary>
+            <div className="decl-body">
+              <div className="decl-section">
+                <div className="decl-label">判断汇总</div>
+                <div className="decl-row">入选 {row.ruleSummary.passCount} · 待复看 {row.ruleSummary.reviewCount} · 淘汰 {row.ruleSummary.rejectCount}</div>
+              </div>
+              <div className="decl-section">
+                <div className="decl-label">继续扩展</div>
+                <div className="decl-row">{walkSummaryText(row)}</div>
+              </div>
+              <button className="mini-button" type="button" onClick={() => setDrawer({ title: "本轮判断说明", sections: rowDecisionSections(row) })}>
+                <PanelRightOpen size={13} />
+                看整体原因
+              </button>
+            </div>
+          </details>
+          <section className="detail-table-wrap">
+            <table className="detail-table">
+              <thead>
+                <tr>
+                  <th>#</th>
+                  <th>视频</th>
+                  <th>作者 / 平台</th>
+                  <th>互动</th>
+                  <th>判断</th>
+                  <th>操作</th>
+                </tr>
+              </thead>
+              <tbody>
+                {row.firstRoundVideos.map((video, index) => (
+                  <tr key={`${video.id}-${index}`}>
+                    <td>{index + 1}</td>
+                    <td>
+                      <Link className="title-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(video.id)}`}>
+                        {video.title}
+                      </Link>
+                    </td>
+                    <td>
+                      <strong>{video.author}</strong>
+                      <div className="muted">{platformLabel(video.platform)}</div>
+                    </td>
+                    <td>
+                      <span className="chip">{metricLabel(video)}</span>
+                    </td>
+                    <td>
+                      <span className="chip blue">{actionLabel(video.decisionAction)}</span>
+                      <div className="muted">点击右侧查看原因</div>
+                    </td>
+                    <td>
+                      <button className="mini-button" type="button" onClick={() => setDrawer({ title: "为什么这样判断", sections: videoDecisionSections(video) })}>
+                        <PanelRightOpen size={13} />
+                        看判断原因
+                      </button>
+                      {video.url ? (
+                        <a className="mini-button ghost" href={video.url} target="_blank" rel="noreferrer">
+                          <ExternalLink size={13} />
+                          原链
+                        </a>
+                      ) : null}
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </section>
+        </>
+      ) : null}
+      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
+    </AppShell>
+  );
+}

+ 111 - 0
web2/features/QueryWalkPage.tsx

@@ -0,0 +1,111 @@
+"use client";
+
+import { useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
+import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import { findLedgerRow, queryWalkActions } from "@/lib/flow-ledger/build";
+import {
+  reasonLabel,
+  sourceEvidence,
+  walkActionImpact,
+  walkActionReason,
+  walkActionSections,
+  walkActionSource,
+  walkActionTitle,
+  walkEdgeLabel,
+  WALK_LANE_KEYS,
+  walkStatusLabel,
+  walkSummaryText
+} from "@/lib/flow-ledger/business";
+import { text } from "@/lib/flow-ledger/format";
+import { useFlowLedger } from "./useFlowLedger";
+
+export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: string }) {
+  const { ledger, loading, error, reload } = useFlowLedger(runId);
+  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
+  const row = ledger ? findLedgerRow(ledger, queryId) : undefined;
+  const actions = row ? queryWalkActions(row) : [];
+
+  return (
+    <AppShell title="继续扩展过程" subtitle={row ? `搜索词:${row.query.text}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={reload}>
+      {loading ? <LoadingState /> : null}
+      {error ? <ErrorState message={error} /> : null}
+      {!loading && !error && !row ? <EmptyState label="没有找到这个搜索词的扩展过程" /> : null}
+      {row ? (
+        <>
+          <details className="declarations" open>
+            <summary>
+              <span className="kw">扩展</span> <b>{row.query.text}</b>
+              <span className="decl-purpose">{sourceEvidence(row)}</span>
+              <span className="tag-mini">{row.walkSummary.total ? `动作 ${row.walkSummary.total}` : "没有继续扩展"}</span>
+            </summary>
+            <div className="decl-body">
+              <div className="decl-section">
+                <div className="decl-label">扩展结论</div>
+                <div className="decl-row">{walkSummaryText(row)}</div>
+              </div>
+              <div className="decl-section">
+                <div className="decl-label">主要停止原因</div>
+                <div className="decl-row">
+                  {Object.entries(row.walkSummary.stopReasons).length
+                    ? Object.entries(row.walkSummary.stopReasons).map(([reason, count]) => `${reasonLabel(reason)} ${count} 次`).join(" · ")
+                    : "本轮没有记录停止原因"}
+                </div>
+              </div>
+            </div>
+          </details>
+          <section className="walk-lanes">
+            {WALK_LANE_KEYS.map((edge) => {
+              const lane = actions.filter((action) => text(action.edge_id || action.edge_type, "") === edge);
+              return (
+                <div className="walk-lane" key={edge}>
+                  <div className="lane-head">{walkEdgeLabel(edge)} · {lane.length}</div>
+                  {lane.slice(0, 12).map((action, index) => (
+                    <button className="walk-node" type="button" key={`${edge}-${index}`} onClick={() => setDrawer({ title: walkActionTitle(action), sections: walkActionSections(action, row) })}>
+                      <b>{walkStatusLabel(text(action.walk_status, ""))}</b>
+                      <span>{walkActionReason(action)}</span>
+                      <small>{walkActionImpact(action)}</small>
+                    </button>
+                  ))}
+                  {!lane.length ? <span className="muted lane-empty">无动作</span> : null}
+                </div>
+              );
+            })}
+          </section>
+          <section className="detail-table-wrap">
+            <table className="detail-table">
+              <thead>
+                <tr>
+                  <th>发生了什么</th>
+                  <th>从哪里来</th>
+                  <th>状态</th>
+                  <th>为什么</th>
+                  <th>对产出有什么影响</th>
+                  <th>说明</th>
+                </tr>
+              </thead>
+              <tbody>
+                {actions.map((action, index) => (
+                  <tr key={`walk-${index}`}>
+                    <td>{walkActionTitle(action)}</td>
+                    <td>{walkActionSource(action, row)}</td>
+                    <td><span className="chip blue">{walkStatusLabel(text(action.walk_status, ""))}</span></td>
+                    <td>{walkActionReason(action)}</td>
+                    <td>{walkActionImpact(action)}</td>
+                    <td>
+                      <button className="mini-button" type="button" onClick={() => setDrawer({ title: walkActionTitle(action), sections: walkActionSections(action, row) })}>
+                        看说明
+                      </button>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </section>
+        </>
+      ) : null}
+      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
+    </AppShell>
+  );
+}

+ 69 - 0
web2/features/RunListPage.tsx

@@ -0,0 +1,69 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import { listRuns } from "@/lib/api/client";
+import type { RunListItem } from "@/lib/api/types";
+import { platformLabel } from "@/lib/flow-ledger/business";
+
+function statusLabel(value?: string | null): string {
+  const labels: Record<string, string> = {
+    completed: "已完成",
+    running: "运行中",
+    failed: "运行失败",
+    pending: "等待处理",
+    success: "已完成"
+  };
+  return value ? labels[value] || "状态待确认" : "状态待确认";
+}
+
+export function RunListPage() {
+  const [runs, setRuns] = useState<RunListItem[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  async function load() {
+    setLoading(true);
+    setError(null);
+    try {
+      const data = await listRuns(new URLSearchParams({ page_size: "50" }));
+      setRuns(data.items || []);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  useEffect(() => {
+    void load();
+  }, []);
+
+  return (
+    <AppShell title="运营流程账本" subtitle="选择一次内容发现任务,查看搜索词如何一路产生内容" onRefresh={load}>
+      <section className="source-block open">
+        <div className="source-summary">选择一次任务,进入完整流程账本。</div>
+      </section>
+      {loading ? <LoadingState /> : null}
+      {error ? <ErrorState message={error} /> : 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 || "未命名任务"}</strong>
+              <span>{run.demand_desc || "暂无任务说明"}</span>
+            </div>
+            <div className="run-row-meta">
+              <span className="chip">{platformLabel(run.platform)}</span>
+              <span className="chip blue">{statusLabel(run.status)}</span>
+              <span>{run.started_at ? `开始时间 ${run.started_at}` : "未记录开始时间"}</span>
+            </div>
+          </Link>
+        ))}
+      </section>
+    </AppShell>
+  );
+}

+ 77 - 0
web2/features/VideoDetailPage.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+import { ExternalLink, PanelRightOpen } from "lucide-react";
+import { useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
+import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
+import { findVideo } from "@/lib/flow-ledger/build";
+import { actionLabel, metricLabel, platformLabel, reasonLabel, scoreBusinessText, videoDecisionSections } from "@/lib/flow-ledger/business";
+import { useFlowLedger } from "./useFlowLedger";
+
+export function VideoDetailPage({ runId, contentId }: { runId: string; contentId: string }) {
+  const { input, loading, error, reload } = useFlowLedger(runId);
+  const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
+  const video = input ? findVideo(input, contentId) : undefined;
+
+  return (
+    <AppShell title="视频详情" subtitle="查看这条内容为什么入选、淘汰或需要复看" runId={runId} showBack onRefresh={reload}>
+      {loading ? <LoadingState /> : null}
+      {error ? <ErrorState message={error} /> : null}
+      {!loading && !error && !video ? <EmptyState label="没有找到这个视频" /> : null}
+      {video ? (
+        <>
+          <details className="declarations" open>
+            <summary>
+              <span className="kw">视频</span> <b>{video.title}</b>
+              <span className="decl-purpose">作者:{video.author} · 平台:{platformLabel(video.platform)}</span>
+              <span className="tag-mini">{actionLabel(video.decisionAction)}</span>
+            </summary>
+            <div className="decl-body">
+              <div className="decl-section">
+                <div className="decl-label">互动</div>
+                <div className="decl-row">{metricLabel(video)}</div>
+              </div>
+              <div className="decl-section">
+                <div className="decl-label">判断</div>
+                <div className="decl-row">
+                  {reasonLabel(video.decisionReason)}
+                  {scoreBusinessText(video.score) ? ` · ${scoreBusinessText(video.score)}` : ""}
+                </div>
+              </div>
+              <div className="decl-section">
+                <div className="decl-label">标签</div>
+                <div className="decl-row">{video.tags.length ? video.tags.join(" / ") : "无标签"}</div>
+              </div>
+            </div>
+          </details>
+          <section className="video-detail-grid">
+            <div className="video-panel">
+              <h2>内容快照</h2>
+              <p>{video.title}</p>
+              <div className="chip-row">
+                <span className="chip">作者:{video.author}</span>
+                <span className="chip blue">{metricLabel(video)}</span>
+              </div>
+              {video.url ? (
+                <a className="mini-button" href={video.url} target="_blank" rel="noreferrer">
+                  <ExternalLink size={13} />
+                  打开原链
+                </a>
+              ) : null}
+            </div>
+            <div className="video-panel">
+              <h2>判断证据</h2>
+              <p>{actionLabel(video.decisionAction)} · {reasonLabel(video.decisionReason)}</p>
+              <button className="mini-button" type="button" onClick={() => setDrawer({ title: "为什么这样判断", sections: videoDecisionSections(video) })}>
+                <PanelRightOpen size={13} />
+                看判断原因
+              </button>
+            </div>
+          </section>
+        </>
+      ) : null}
+      <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
+    </AppShell>
+  );
+}

+ 32 - 0
web2/features/useFlowLedger.ts

@@ -0,0 +1,32 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { getFlowInput } from "@/lib/api/client";
+import type { FlowInput } from "@/lib/api/types";
+import { buildFlowLedger } from "@/lib/flow-ledger/build";
+import type { FlowLedger } from "@/lib/flow-ledger/types";
+
+export function useFlowLedger(runId: string) {
+  const [input, setInput] = useState<FlowInput | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      setInput(await getFlowInput(runId));
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setLoading(false);
+    }
+  }, [runId]);
+
+  useEffect(() => {
+    void load();
+  }, [load]);
+
+  const ledger: FlowLedger | null = useMemo(() => (input ? buildFlowLedger(input) : null), [input]);
+  return { input, ledger, loading, error, reload: load };
+}

+ 122 - 0
web2/lib/api/client.ts

@@ -0,0 +1,122 @@
+import type {
+  ContentItemsResponse,
+  DashboardResponse,
+  QueryListResponse,
+  RawRecord,
+  RunListResponse,
+  RuntimeFileResponse,
+  TimelineResponse
+} from "./types";
+
+const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000";
+
+export class ApiError extends Error {
+  status: number;
+  detail: unknown;
+
+  constructor(message: string, status: number, detail: unknown) {
+    super(message);
+    this.name = "ApiError";
+    this.status = status;
+    this.detail = detail;
+  }
+}
+
+export function apiBaseUrl() {
+  return (
+    process.env.NEXT_PUBLIC_CONTENTFIND_API_BASE_URL ||
+    process.env.VITE_CONTENTFIND_API_BASE_URL ||
+    DEFAULT_API_BASE_URL
+  ).replace(/\/$/, "");
+}
+
+async function request<T>(path: string): Promise<T> {
+  const response = await fetch(`${apiBaseUrl()}${path}`, {
+    headers: { Accept: "application/json" },
+    cache: "no-store"
+  });
+  if (!response.ok) {
+    let detail: unknown = null;
+    try {
+      detail = await response.json();
+    } catch {
+      detail = await response.text();
+    }
+    throw new ApiError(`request failed: ${path}`, response.status, detail);
+  }
+  return response.json() as Promise<T>;
+}
+
+async function requestOptionalRuntimeFile(runId: string, filename: string, limit = 500): Promise<RawRecord[]> {
+  try {
+    const file = await getRuntimeFile(runId, filename, limit);
+    return file.records || [];
+  } catch (error) {
+    if (error instanceof ApiError && error.status === 404) return [];
+    throw error;
+  }
+}
+
+export function listRuns(params = new URLSearchParams()) {
+  const query = params.toString();
+  return request<RunListResponse>(`/runs${query ? `?${query}` : ""}`);
+}
+
+export function getDashboard(runId: string) {
+  return request<DashboardResponse>(`/runs/${encodeURIComponent(runId)}/dashboard`);
+}
+
+export function getQueries(runId: string) {
+  return request<QueryListResponse>(`/runs/${encodeURIComponent(runId)}/queries`);
+}
+
+export function getTimeline(runId: string) {
+  return request<TimelineResponse>(`/runs/${encodeURIComponent(runId)}/timeline`);
+}
+
+export function getContentItems(runId: string) {
+  return request<ContentItemsResponse>(`/runs/${encodeURIComponent(runId)}/content-items`);
+}
+
+export function getRuntimeFile(runId: string, filename: string, limit = 500) {
+  const safeFilename = encodeURIComponent(filename);
+  return request<RuntimeFileResponse>(
+    `/runs/${encodeURIComponent(runId)}/runtime-files/${safeFilename}?limit=${limit}`
+  );
+}
+
+export async function getFlowInput(runId: string) {
+  const [
+    dashboard,
+    queries,
+    contentItems,
+    timeline,
+    searchQueries,
+    searchClues,
+    ruleDecisions,
+    walkActions,
+    sourcePathRecords
+  ] = await Promise.all([
+    getDashboard(runId),
+    getQueries(runId),
+    getContentItems(runId),
+    getTimeline(runId),
+    requestOptionalRuntimeFile(runId, "search_queries.jsonl"),
+    requestOptionalRuntimeFile(runId, "search_clues.jsonl"),
+    requestOptionalRuntimeFile(runId, "rule_decisions.jsonl"),
+    requestOptionalRuntimeFile(runId, "walk_actions.jsonl"),
+    requestOptionalRuntimeFile(runId, "source_path_records.jsonl")
+  ]);
+
+  return {
+    dashboard,
+    queries,
+    contentItems,
+    timeline,
+    searchQueries,
+    searchClues,
+    ruleDecisions,
+    walkActions,
+    sourcePathRecords
+  };
+}

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

@@ -0,0 +1,96 @@
+export type DataOrigin = "production_db" | "runtime_export" | "mixed_with_runtime_export";
+
+export type RawRecord = Record<string, unknown>;
+
+export type RunListItem = {
+  run_id: string;
+  policy_run_id?: string | null;
+  status?: string | null;
+  current_step?: string | null;
+  platform?: string | null;
+  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;
+  completed_at?: string | null;
+};
+
+export type RunListResponse = {
+  items: RunListItem[];
+  page: number;
+  page_size: number;
+  total: number;
+  data_origin: DataOrigin;
+};
+
+export type RuntimeFileResponse = {
+  run_id: string;
+  filename: string;
+  data_origin: DataOrigin;
+  data?: RawRecord;
+  records?: RawRecord[];
+  offset?: number;
+  limit?: number;
+  total?: number;
+};
+
+export type DashboardResponse = {
+  run_id: string;
+  summary: RawRecord;
+  counts: Record<string, number>;
+  files: Record<string, boolean>;
+  validation: RawRecord;
+  final_output_summary: RawRecord;
+  business_summary?: RawRecord;
+  data_origin: DataOrigin;
+  links?: Record<string, string>;
+};
+
+export type QueryListResponse = {
+  run_id: string;
+  items: RawRecord[];
+  total: number;
+  data_origin: DataOrigin;
+};
+
+export type ContentItemsResponse = {
+  run_id: string;
+  items: RawRecord[];
+  total: number;
+  data_origin: DataOrigin;
+};
+
+export type TimelineItem = {
+  source: string;
+  stage?: string | null;
+  event_type?: string | null;
+  status?: string | null;
+  timestamp?: string | null;
+  error_code?: string | null;
+  walk_action_id?: string | null;
+  record: RawRecord;
+};
+
+export type TimelineResponse = {
+  run_id: string;
+  items: TimelineItem[];
+  total: number;
+  data_origin: DataOrigin;
+  summary: RawRecord;
+};
+
+export type FlowInput = {
+  dashboard: DashboardResponse;
+  queries: QueryListResponse;
+  contentItems: ContentItemsResponse;
+  timeline: TimelineResponse;
+  searchQueries: RawRecord[];
+  searchClues: RawRecord[];
+  ruleDecisions: RawRecord[];
+  walkActions: RawRecord[];
+  sourcePathRecords: RawRecord[];
+};

+ 248 - 0
web2/lib/flow-ledger/build.ts

@@ -0,0 +1,248 @@
+import type { FlowInput, RawRecord } from "@/lib/api/types";
+import type { AssetSummary, FlowLedger, FlowLedgerRow, QuerySummary, RuleSummary, SourceSummary, VideoRef, WalkSummary } from "./types";
+import { asRecord, countBy, maybeText, scoreLabel, text, textList } from "./format";
+
+function uniqueRecords(records: RawRecord[], getId: (row: RawRecord) => string): RawRecord[] {
+  const seen = new Set<string>();
+  const result: RawRecord[] = [];
+  records.forEach((row) => {
+    const id = getId(row);
+    if (!id || seen.has(id)) return;
+    seen.add(id);
+    result.push(row);
+  });
+  return result;
+}
+
+function queryId(row: RawRecord): string {
+  return text(row.search_query_id, "");
+}
+
+function contentIds(row: RawRecord): string[] {
+  return [
+    maybeText(row.content_discovery_id),
+    maybeText(row.platform_content_id),
+    maybeText(row.decision_target_id)
+  ].filter(Boolean) as string[];
+}
+
+function isFirstRoundContent(row: RawRecord, qid: string): boolean {
+  const own = row.search_query_id === qid || textList(row.matched_search_query_ids).includes(qid);
+  if (!own) return false;
+  const previous = text(row.previous_discovery_step, "");
+  if (!previous) return true;
+  return !/tag|author|next_page|walk|pagination/i.test(previous);
+}
+
+function queryRelatedContentIds(contents: RawRecord[], qid: string): Set<string> {
+  const ids = new Set<string>();
+  contents.filter((row) => isFirstRoundContent(row, qid)).forEach((row) => {
+    contentIds(row).forEach((id) => ids.add(id));
+  });
+  return ids;
+}
+
+function decisionBelongsToQuery(decision: RawRecord, qid: string, relatedContentIds: Set<string>): boolean {
+  if (decision.search_query_id === qid) return true;
+  const sourceEvidence = asRecord(decision.source_evidence);
+  if (sourceEvidence.search_query_id === qid) return true;
+  const target = text(decision.decision_target_id, "");
+  return Boolean(target && relatedContentIds.has(target));
+}
+
+function decisionForContent(decisions: RawRecord[], content: RawRecord): RawRecord | undefined {
+  const ids = new Set(contentIds(content));
+  return decisions.find((decision) => {
+    const target = text(decision.decision_target_id, "");
+    return Boolean(target && ids.has(target));
+  });
+}
+
+function sourceSummary(query: RawRecord, sourcePaths: RawRecord[]): SourceSummary {
+  const seedRef = asRecord(query.pattern_seed_ref);
+  const path = sourcePaths.find(
+    (row) => row.source_path_type === "pattern_to_search_query" && row.to_node_id === query.search_query_id
+  );
+  const sourceKind = text(query.discovery_start_source || seedRef.source_kind || path?.discovery_start_source, "来源待确认");
+  const sourceRef =
+    maybeText(seedRef.source_post_id) ||
+    maybeText(seedRef.pattern_execution_id) ||
+    maybeText(path?.source_evidence_ref) ||
+    "source ref 待确认";
+  const terms = textList(query.query_source_terms);
+  const evidenceParts = [
+    maybeText(seedRef.seed_term),
+    terms.length ? terms.join(" / ") : undefined
+  ].filter(Boolean);
+  return {
+    sourceKind,
+    evidenceLabel: evidenceParts.join(" / ") || "来源证据待确认",
+    sourceRef,
+    terms,
+    raw: seedRef
+  };
+}
+
+function querySummary(query: RawRecord, clue?: RawRecord): QuerySummary {
+  return {
+    id: text(query.search_query_id, ""),
+    text: text(query.search_query, "搜索词待确认"),
+    method: text(query.search_query_generation_method, "unknown"),
+    sourceTerms: textList(query.query_source_terms),
+    effectStatus: text(clue?.search_query_effect_status || query.search_query_effect_status, "pending"),
+    raw: query,
+    clue
+  };
+}
+
+function videoRef(content: RawRecord, decision?: RawRecord): VideoRef {
+  const stats = asRecord(content.statistics);
+  return {
+    id: text(content.platform_content_id || content.content_discovery_id, "content"),
+    contentDiscoveryId: text(content.content_discovery_id, ""),
+    platformContentId: text(content.platform_content_id, ""),
+    title: text(content.description || content.title, "无标题视频"),
+    author: text(content.author_display_name || content.platform_author_id, "未知作者"),
+    platform: text(content.platform, "unknown"),
+    url: maybeText(content.platform_content_url) || null,
+    tags: textList(content.tags),
+    statistics: Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, Number(value) || 0])),
+    decisionAction: text(decision?.decision_action, "未判断"),
+    decisionReason: text(decision?.decision_reason_code, "no_decision"),
+    score: scoreLabel(decision?.score ?? asRecord(decision?.scorecard).total_score),
+    raw: content,
+    decision
+  };
+}
+
+function ruleSummary(decisions: RawRecord[]): RuleSummary {
+  const actionCounts = countBy(decisions, (row) => text(row.decision_action, "unknown"));
+  const reasons = countBy(decisions, (row) => maybeText(row.decision_reason_code));
+  const primaryReason = Object.entries(reasons).sort((a, b) => b[1] - a[1])[0]?.[0] || "无判断原因";
+  const scores = decisions.map((row) => row.score ?? asRecord(row.scorecard).total_score).filter((value) => value != null);
+  const numeric = scores.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
+  const scoreText = numeric.length ? `${Math.round(numeric.reduce((a, b) => a + b, 0) / numeric.length)} 平均分` : "无分数";
+  return {
+    total: decisions.length,
+    passCount: actionCounts.ADD_CONTENT_TO_POOL || 0,
+    reviewCount: actionCounts.KEEP_CONTENT_FOR_REVIEW || 0,
+    rejectCount: actionCounts.REJECT_CONTENT || 0,
+    unknownCount: actionCounts.unknown || 0,
+    primaryReason,
+    scoreLabel: scoreText,
+    decisions
+  };
+}
+
+function walkSummary(qid: string, relatedContentIds: Set<string>, actions: RawRecord[]): WalkSummary {
+  const related = actions.filter((action) => {
+    if (action.from_node_id === qid || action.to_node_id === qid) return true;
+    const from = text(action.from_node_id, "");
+    const to = text(action.to_node_id, "");
+    const target = text(action.decision_target_id, "");
+    return Boolean((from && relatedContentIds.has(from)) || (to && relatedContentIds.has(to)) || (target && relatedContentIds.has(target)));
+  });
+  const depths = related.map((row) => Number(row.depth)).filter((n) => Number.isFinite(n));
+  return {
+    total: related.length,
+    maxDepth: depths.length ? Math.max(...depths) : 0,
+    edgeCounts: countBy(related, (row) => text(row.edge_id || row.edge_type, "unknown")),
+    statusCounts: countBy(related, (row) => text(row.walk_status, "unknown")),
+    stopReasons: countBy(related, (row) => maybeText(row.reason_code)),
+    actions: related
+  };
+}
+
+function assetSummary(decisions: RawRecord[], sourcePaths: RawRecord[], finalSummary: RawRecord): AssetSummary {
+  const decisionIds = new Set(decisions.map((row) => text(row.decision_id, "")).filter(Boolean));
+  const paths = sourcePaths.filter((path) => {
+    const type = text(path.source_path_type, "");
+    if (type !== "decision_to_asset") return false;
+    const from = text(path.from_node_id, "");
+    const decision = text(path.decision_id, "");
+    return Boolean((from && decisionIds.has(from)) || (decision && decisionIds.has(decision)));
+  });
+  const finalCount = Number(finalSummary.asset_count || finalSummary.content_asset_count || finalSummary.record_count || 0) || 0;
+  return {
+    assetCount: paths.length || decisions.filter((row) => row.decision_action === "ADD_CONTENT_TO_POOL").length,
+    finalCount,
+    pathCount: paths.length,
+    paths
+  };
+}
+
+export function buildFlowLedger(input: FlowInput): FlowLedger {
+  const queryRecords = uniqueRecords(
+    [...input.searchQueries, ...input.queries.items],
+    (row) => queryId(row)
+  ).filter((row) => queryId(row));
+  const cluesByQuery = new Map(input.searchClues.map((row) => [text(row.search_query_id, ""), row]));
+  input.queries.items.forEach((row) => {
+    const clue = asRecord(row.search_clue);
+    const id = text(row.search_query_id, "");
+    if (id && Object.keys(clue).length && !cluesByQuery.has(id)) cluesByQuery.set(id, clue);
+  });
+
+  const rows: FlowLedgerRow[] = queryRecords.map((query) => {
+    const id = queryId(query);
+    const relatedContentIds = queryRelatedContentIds(input.contentItems.items, id);
+    const videos = input.contentItems.items
+      .filter((content) => isFirstRoundContent(content, id))
+      .map((content) => videoRef(content, decisionForContent(input.ruleDecisions, content)));
+    const decisions = input.ruleDecisions.filter((decision) => decisionBelongsToQuery(decision, id, relatedContentIds));
+    const duplicateCount = Math.max(0, videos.length - new Set(videos.map((video) => video.platformContentId || video.contentDiscoveryId)).size);
+    return {
+      id,
+      source: sourceSummary(query, input.sourcePathRecords),
+      query: querySummary(query, cluesByQuery.get(id)),
+      firstRoundVideos: videos,
+      duplicateCount,
+      ruleSummary: ruleSummary(decisions),
+      walkSummary: walkSummary(id, relatedContentIds, input.walkActions),
+      finalAssets: assetSummary(decisions, input.sourcePathRecords, input.dashboard.final_output_summary || {})
+    };
+  });
+
+  return {
+    runId: input.dashboard.run_id,
+    rows,
+    raw: {
+      queries: queryRecords,
+      contentItems: input.contentItems.items,
+      decisions: input.ruleDecisions,
+      walkActions: input.walkActions,
+      sourcePaths: input.sourcePathRecords
+    }
+  };
+}
+
+export function findLedgerRow(ledger: FlowLedger, queryId: string): FlowLedgerRow | undefined {
+  return ledger.rows.find((row) => row.id === queryId);
+}
+
+export function findVideo(input: FlowInput, contentId: string): VideoRef | undefined {
+  const content = input.contentItems.items.find((item) => {
+    return [item.platform_content_id, item.content_discovery_id].some((id) => text(id, "") === contentId);
+  });
+  if (!content) return undefined;
+  return videoRef(content, decisionForContent(input.ruleDecisions, content));
+}
+
+export function queryWalkActions(row: FlowLedgerRow): RawRecord[] {
+  return row.walkSummary.actions;
+}
+
+export function evidenceObject(row: FlowLedgerRow): RawRecord {
+  return {
+    source: row.source.raw,
+    query: row.query.raw,
+    clue: row.query.clue,
+    first_round_videos: row.firstRoundVideos.map((video) => ({
+      content: video.raw,
+      decision: video.decision
+    })),
+    decisions: row.ruleSummary.decisions,
+    walk_actions: row.walkSummary.actions,
+    asset_paths: row.finalAssets.paths
+  };
+}

+ 324 - 0
web2/lib/flow-ledger/business.ts

@@ -0,0 +1,324 @@
+import type { RawRecord } from "@/lib/api/types";
+import type { FlowLedgerRow, VideoRef } from "./types";
+import { asRecord, maybeText, text, textList } from "./format";
+
+export type BusinessTone = "neutral" | "good" | "warn" | "bad" | "info";
+
+export type BusinessSection = {
+  label: string;
+  value?: string;
+  tone?: BusinessTone;
+  items?: string[];
+};
+
+const UNKNOWN = "系统原因待补充";
+
+const ACTION_LABELS: Record<string, string> = {
+  ADD_CONTENT_TO_POOL: "入选",
+  KEEP_CONTENT_FOR_REVIEW: "待复看",
+  REJECT_CONTENT: "淘汰",
+  未判断: "未进入判断"
+};
+
+const ACTION_RESULT: Record<string, string> = {
+  ADD_CONTENT_TO_POOL: "这条内容可以进入后续沉淀。",
+  KEEP_CONTENT_FOR_REVIEW: "这条内容需要人工再看一眼。",
+  REJECT_CONTENT: "这条内容不建议继续使用。",
+  未判断: "系统还没有给这条内容做判断。"
+};
+
+const REASON_LABELS: Record<string, string> = {
+  content_pattern_recall_required: "内容和本次需求没有形成有效关联",
+  blocked_by_rule_decision: "上一步内容未通过判断",
+  missing_content_portrait: "缺少判断所需的内容画像",
+  category_or_element_binding_required: "没有命中本次需求的类目或要素",
+  missing_score: "没有足够信息进入打分",
+  v4_query_and_platform_pass: "搜索词和平台表现都符合要求",
+  v4_query_or_score_below_threshold: "相关性或平台表现未达到要求",
+  v4_technical_retry_needed: "系统需要重试后再判断",
+  v4_allow_walk_denied: "不适合继续向外扩展",
+  no_decision: "暂未产生判断结果",
+  无原因: "暂未产生判断原因"
+};
+
+const REASON_IMPACT: Record<string, string> = {
+  content_pattern_recall_required: "不会用它继续扩展,也不会进入最终沉淀。",
+  blocked_by_rule_decision: "这条扩展链路到这里停止,避免把低相关内容继续放大。",
+  missing_content_portrait: "需要补齐互动、作者或画像数据后,才能做稳定判断。",
+  category_or_element_binding_required: "建议回到需求词或分类条件,检查本次搜索方向是否过宽。",
+  missing_score: "系统先被基础条件拦下,所以没有展示分数。",
+  v4_query_and_platform_pass: "可以作为候选内容继续流转。",
+  v4_query_or_score_below_threshold: "建议换搜索词或降低对这类内容的依赖。",
+  v4_technical_retry_needed: "建议稍后重跑或交给研发排查采集状态。",
+  v4_allow_walk_denied: "本条内容不再带出更多视频。",
+  no_decision: "当前只作为发现记录展示,暂不影响最终沉淀。",
+  无原因: "当前只作为发现记录展示,暂不影响最终沉淀。"
+};
+
+const SOURCE_LABELS: Record<string, string> = {
+  pattern_itemset: "来自需求 Pattern",
+  pattern_search_query: "来自需求 Pattern",
+  pattern_seed_ref: "来自需求 Pattern",
+  demand_pattern: "来自需求 Pattern",
+  source_seed: "来自需求种子",
+  search_query_direct: "直接搜索",
+  来源待确认: "来源待确认"
+};
+
+const METHOD_LABELS: Record<string, string> = {
+  item_single: "使用原始需求词",
+  llm_variant: "系统扩写后的搜索词",
+  manual: "人工指定搜索词",
+  unknown: "生成方式待确认"
+};
+
+const EFFECT_LABELS: Record<string, string> = {
+  success: "已找到可用结果",
+  rule_blocked: "找到结果,但未通过内容判断",
+  pending: "等待处理",
+  failed: "搜索失败",
+  skipped: "已跳过"
+};
+
+const WALK_EDGE_LABELS: Record<string, string> = {
+  query_next_page: "翻下一页找更多",
+  hashtag_to_query: "根据视频标签继续搜",
+  video_to_author: "查看作者更多作品",
+  path_stop: "到这里停止",
+  tag_query: "根据视频标签继续搜",
+  author_works: "查看作者更多作品",
+  terminal: "到这里停止"
+};
+
+export const WALK_LANE_KEYS = [
+  "query_next_page",
+  "hashtag_to_query",
+  "video_to_author",
+  "path_stop"
+];
+
+const WALK_STATUS_LABELS: Record<string, string> = {
+  success: "已执行",
+  skipped: "未继续",
+  failed: "执行失败",
+  pending: "等待处理"
+};
+
+const PLATFORM_LABELS: Record<string, string> = {
+  douyin: "抖音",
+  tiktok: "TikTok",
+  bilibili: "B 站",
+  kuaishou: "快手",
+  xhs: "小红书"
+};
+
+export function actionLabel(value: string): string {
+  return ACTION_LABELS[value] || "未进入判断";
+}
+
+export function actionTone(value: string): BusinessTone {
+  if (value === "ADD_CONTENT_TO_POOL") return "good";
+  if (value === "KEEP_CONTENT_FOR_REVIEW") return "warn";
+  if (value === "REJECT_CONTENT") return "bad";
+  return "neutral";
+}
+
+export function reasonLabel(value: string): string {
+  return REASON_LABELS[value] || UNKNOWN;
+}
+
+export function reasonImpact(value: string): string {
+  return REASON_IMPACT[value] || "这条原因还没有配置运营解释,当前不会展示内部字段。";
+}
+
+export function sourceLabel(value: string): string {
+  return SOURCE_LABELS[value] || SOURCE_LABELS.来源待确认;
+}
+
+export function methodLabel(value: string): string {
+  return METHOD_LABELS[value] || METHOD_LABELS.unknown;
+}
+
+export function effectLabel(value: string): string {
+  return EFFECT_LABELS[value] || "处理状态待确认";
+}
+
+export function walkEdgeLabel(value: string): string {
+  return WALK_EDGE_LABELS[value] || UNKNOWN;
+}
+
+export function walkStatusLabel(value: string): string {
+  return WALK_STATUS_LABELS[value] || "状态待确认";
+}
+
+export function scoreBusinessText(value?: string): string | undefined {
+  if (!value || value === "无分数") return undefined;
+  return `综合分 ${value}`;
+}
+
+export function platformLabel(value?: string | null): string {
+  if (!value) return "平台待确认";
+  return PLATFORM_LABELS[value.toLowerCase()] || value;
+}
+
+export function metricLabel(video: VideoRef): string {
+  const digg = video.statistics.digg_count || video.statistics.like_count || 0;
+  const comment = video.statistics.comment_count || 0;
+  const share = video.statistics.share_count || 0;
+  return `点赞 ${digg} · 评论 ${comment} · 转发 ${share}`;
+}
+
+export function sourceEvidence(row: FlowLedgerRow): string {
+  if (row.source.terms.length) return `依据:${row.source.terms.join(" / ")}`;
+  if (row.source.evidenceLabel && row.source.evidenceLabel !== "来源证据待确认") return row.source.evidenceLabel;
+  return "来源证据待确认";
+}
+
+export function ruleHeadline(row: FlowLedgerRow): string {
+  const total = row.ruleSummary.total;
+  if (!total) return "首轮内容暂未进入判断";
+  if (row.ruleSummary.passCount) return `有 ${row.ruleSummary.passCount} 条内容可进入沉淀`;
+  if (row.ruleSummary.reviewCount) return `有 ${row.ruleSummary.reviewCount} 条内容需要人工复看`;
+  return "本轮没有可沉淀内容";
+}
+
+export function rulePrimaryReason(row: FlowLedgerRow): string {
+  if (!row.ruleSummary.total) return "还没有产生判断结果";
+  return reasonLabel(row.ruleSummary.primaryReason);
+}
+
+export function ruleBrief(row: FlowLedgerRow): string {
+  if (!row.ruleSummary.total) return "简要:首轮内容还没有进入判断。";
+  if (row.ruleSummary.passCount) return `简要:有 ${row.ruleSummary.passCount} 条内容可以继续使用。`;
+  if (row.ruleSummary.reviewCount) return `简要:有 ${row.ruleSummary.reviewCount} 条内容需要人工复看,先别直接沉淀。`;
+  return `简要:本轮内容不建议沉淀,主要因为${rulePrimaryReason(row)}。`;
+}
+
+export function videoDecisionSections(video: VideoRef): BusinessSection[] {
+  const score = scoreBusinessText(video.score);
+  return [
+    {
+      label: "判断结论",
+      value: actionLabel(video.decisionAction),
+      tone: actionTone(video.decisionAction)
+    },
+    {
+      label: "为什么这样判断",
+      value: reasonLabel(video.decisionReason)
+    },
+    {
+      label: "对流程的影响",
+      value: ACTION_RESULT[video.decisionAction] || reasonImpact(video.decisionReason)
+    },
+    {
+      label: "关键证据",
+      items: [
+        `视频标题:${video.title}`,
+        `作者:${video.author}`,
+        metricLabel(video),
+        score,
+        video.tags.length ? `标签:${video.tags.slice(0, 8).join(" / ")}` : undefined
+      ].filter(Boolean) as string[]
+    }
+  ];
+}
+
+export function rowDecisionSections(row: FlowLedgerRow): BusinessSection[] {
+  return [
+    {
+      label: "本轮结论",
+      value: ruleHeadline(row),
+      tone: row.ruleSummary.passCount ? "good" : row.ruleSummary.reviewCount ? "warn" : row.ruleSummary.total ? "bad" : "neutral"
+    },
+    {
+      label: "主要原因",
+      value: rulePrimaryReason(row)
+    },
+    {
+      label: "运营影响",
+      value: row.ruleSummary.total ? reasonImpact(row.ruleSummary.primaryReason) : "当前只完成搜索发现,还没有进入内容判断。"
+    },
+    {
+      label: "判断分布",
+      items: [
+        `入选 ${row.ruleSummary.passCount} 条`,
+        `待复看 ${row.ruleSummary.reviewCount} 条`,
+        `淘汰 ${row.ruleSummary.rejectCount} 条`
+      ]
+    }
+  ];
+}
+
+export function walkSummaryText(row: FlowLedgerRow): string {
+  if (!row.walkSummary.total) return "本轮没有继续扩展";
+  const stop = Object.entries(row.walkSummary.stopReasons).sort((a, b) => b[1] - a[1])[0]?.[0];
+  if (stop) return `${row.walkSummary.total} 次扩展动作,主要因为“${reasonLabel(stop)}”停止`;
+  return `${row.walkSummary.total} 次扩展动作`;
+}
+
+export function assetSummaryText(row: FlowLedgerRow): string {
+  if (row.finalAssets.assetCount || row.finalAssets.finalCount) {
+    return `已沉淀 ${row.finalAssets.assetCount || row.finalAssets.finalCount} 条内容`;
+  }
+  return "暂无可沉淀内容";
+}
+
+export function walkActionTitle(action: RawRecord): string {
+  return walkEdgeLabel(text(action.edge_id || action.edge_type, ""));
+}
+
+export function walkActionReason(action: RawRecord): string {
+  const reason = maybeText(action.reason_code);
+  if (reason) return reasonLabel(reason);
+  return "这一步没有记录额外原因";
+}
+
+export function walkActionImpact(action: RawRecord): string {
+  const reason = maybeText(action.reason_code);
+  if (reason) return reasonImpact(reason);
+  const status = text(action.walk_status, "");
+  if (status === "success") return "这一步已经执行,并把结果带入后续流程。";
+  if (status === "skipped") return "这一步没有继续执行。";
+  return "这一步暂不影响最终沉淀。";
+}
+
+export function walkActionSource(action: RawRecord, row: FlowLedgerRow): string {
+  const fromType = text(action.from_node_type, "");
+  if (fromType.includes("query")) return `从搜索词“${row.query.text}”出发`;
+  if (fromType.includes("content") || fromType.includes("video")) return "从首轮视频出发";
+  if (fromType.includes("tag")) return "从视频标签出发";
+  return "从上一环节出发";
+}
+
+export function walkActionSections(action: RawRecord, row: FlowLedgerRow): BusinessSection[] {
+  return [
+    {
+      label: "发生了什么",
+      value: walkActionTitle(action),
+      tone: text(action.walk_status, "") === "success" ? "good" : "neutral"
+    },
+    {
+      label: "从哪里来",
+      value: walkActionSource(action, row)
+    },
+    {
+      label: "结果",
+      value: walkStatusLabel(text(action.walk_status, ""))
+    },
+    {
+      label: "为什么",
+      value: walkActionReason(action)
+    },
+    {
+      label: "对产出的影响",
+      value: walkActionImpact(action)
+    }
+  ];
+}
+
+export function clueText(row: FlowLedgerRow): string {
+  const clue = asRecord(row.query.clue);
+  const queries = textList(clue.generated_queries);
+  if (queries.length) return `系统还尝试了 ${queries.length} 个相近搜索词`;
+  return "";
+}

+ 75 - 0
web2/lib/flow-ledger/format.ts

@@ -0,0 +1,75 @@
+import type { RawRecord } from "@/lib/api/types";
+
+export function asRecord(value: unknown): RawRecord {
+  return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {};
+}
+
+export function asArray(value: unknown): unknown[] {
+  return Array.isArray(value) ? value : [];
+}
+
+export function text(value: unknown, fallback = "未记录"): string {
+  if (typeof value === "string" && value.trim()) return value;
+  if (typeof value === "number" || typeof value === "boolean") return String(value);
+  return fallback;
+}
+
+export function maybeText(value: unknown): string | undefined {
+  if (typeof value === "string" && value.trim()) return value;
+  if (typeof value === "number" || typeof value === "boolean") return String(value);
+  return undefined;
+}
+
+export function textList(value: unknown): string[] {
+  if (!Array.isArray(value)) return [];
+  return value.map((item) => text(item, "")).filter(Boolean);
+}
+
+export function countBy<T>(items: T[], getKey: (item: T) => string | undefined): Record<string, number> {
+  return items.reduce<Record<string, number>>((acc, item) => {
+    const key = getKey(item);
+    if (!key) return acc;
+    acc[key] = (acc[key] || 0) + 1;
+    return acc;
+  }, {});
+}
+
+export function compactCountMap(counts: Record<string, number>, limit = 3): string {
+  const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
+  if (!entries.length) return "无";
+  return entries.slice(0, limit).map(([key, value]) => `${label(key)} ${value}`).join(" · ");
+}
+
+export function label(value: string): string {
+  const labels: Record<string, string> = {
+    item_single: "使用原始需求词",
+    llm_variant: "系统扩写搜索词",
+    tag_query: "根据标签继续搜",
+    author_works: "作者作品",
+    query_next_page: "翻页",
+    search_query_direct: "首轮搜索",
+    pattern_itemset: "来自需求 Pattern",
+    pattern_search_query: "来自需求 Pattern",
+    ADD_CONTENT_TO_POOL: "入选",
+    KEEP_CONTENT_FOR_REVIEW: "待复看",
+    REJECT_CONTENT: "淘汰",
+    success: "成功",
+    skipped: "跳过",
+    failed: "失败",
+    rule_blocked: "规则阻断",
+    pending: "待处理"
+  };
+  return labels[value] || "系统原因待补充";
+}
+
+export function scoreLabel(value: unknown): string {
+  if (typeof value === "number" && Number.isFinite(value)) return `${Math.round(value)}`;
+  if (typeof value === "string" && value.trim()) return value;
+  return "未进入打分";
+}
+
+export function shortId(value: unknown): string {
+  const raw = text(value, "");
+  if (raw.length <= 18) return raw || "未记录";
+  return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
+}

+ 88 - 0
web2/lib/flow-ledger/types.ts

@@ -0,0 +1,88 @@
+import type { RawRecord } from "@/lib/api/types";
+
+export type CountMap = Record<string, number>;
+
+export type VideoRef = {
+  id: string;
+  contentDiscoveryId: string;
+  platformContentId: string;
+  title: string;
+  author: string;
+  platform: string;
+  url?: string | null;
+  tags: string[];
+  statistics: CountMap;
+  decisionAction: string;
+  decisionReason: string;
+  score?: string;
+  raw: RawRecord;
+  decision?: RawRecord;
+};
+
+export type SourceSummary = {
+  sourceKind: string;
+  evidenceLabel: string;
+  sourceRef: string;
+  terms: string[];
+  raw?: RawRecord;
+};
+
+export type QuerySummary = {
+  id: string;
+  text: string;
+  method: string;
+  sourceTerms: string[];
+  effectStatus: string;
+  raw: RawRecord;
+  clue?: RawRecord;
+};
+
+export type RuleSummary = {
+  total: number;
+  passCount: number;
+  reviewCount: number;
+  rejectCount: number;
+  unknownCount: number;
+  primaryReason: string;
+  scoreLabel: string;
+  decisions: RawRecord[];
+};
+
+export type WalkSummary = {
+  total: number;
+  maxDepth: number;
+  edgeCounts: CountMap;
+  statusCounts: CountMap;
+  stopReasons: CountMap;
+  actions: RawRecord[];
+};
+
+export type AssetSummary = {
+  assetCount: number;
+  finalCount: number;
+  pathCount: number;
+  paths: RawRecord[];
+};
+
+export type FlowLedgerRow = {
+  id: string;
+  source: SourceSummary;
+  query: QuerySummary;
+  firstRoundVideos: VideoRef[];
+  duplicateCount: number;
+  ruleSummary: RuleSummary;
+  walkSummary: WalkSummary;
+  finalAssets: AssetSummary;
+};
+
+export type FlowLedger = {
+  runId: string;
+  rows: FlowLedgerRow[];
+  raw: {
+    queries: RawRecord[];
+    contentItems: RawRecord[];
+    decisions: RawRecord[];
+    walkActions: RawRecord[];
+    sourcePaths: RawRecord[];
+  };
+};

+ 6 - 0
web2/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+/// <reference path="./.next/types/routes.d.ts" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 7 - 0
web2/next.config.ts

@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  reactStrictMode: true
+};
+
+export default nextConfig;

+ 1033 - 0
web2/package-lock.json

@@ -0,0 +1,1033 @@
+{
+  "name": "content-find-agent-web2",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "content-find-agent-web2",
+      "version": "0.1.0",
+      "dependencies": {
+        "lucide-react": "^0.468.0",
+        "next": "^15.1.0",
+        "react": "^19.0.0",
+        "react-dom": "^19.0.0"
+      },
+      "devDependencies": {
+        "@types/node": "^22.10.0",
+        "@types/react": "^19.0.0",
+        "@types/react-dom": "^19.0.0",
+        "typescript": "^5.7.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@next/env": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
+      "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
+      "license": "MIT"
+    },
+    "node_modules/@next/swc-darwin-arm64": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
+      "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-darwin-x64": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
+      "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-gnu": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
+      "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-musl": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
+      "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-gnu": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
+      "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-musl": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
+      "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-arm64-msvc": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
+      "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
+      "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.15",
+      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.21",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
+      "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.17",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+      "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001799",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+      "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/client-only": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/lucide-react": {
+      "version": "0.468.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
+      "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/next": {
+      "version": "15.5.19",
+      "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
+      "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@next/env": "15.5.19",
+        "@swc/helpers": "0.5.15",
+        "caniuse-lite": "^1.0.30001579",
+        "postcss": "8.4.31",
+        "styled-jsx": "5.1.6"
+      },
+      "bin": {
+        "next": "dist/bin/next"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+      },
+      "optionalDependencies": {
+        "@next/swc-darwin-arm64": "15.5.19",
+        "@next/swc-darwin-x64": "15.5.19",
+        "@next/swc-linux-arm64-gnu": "15.5.19",
+        "@next/swc-linux-arm64-musl": "15.5.19",
+        "@next/swc-linux-x64-gnu": "15.5.19",
+        "@next/swc-linux-x64-musl": "15.5.19",
+        "@next/swc-win32-arm64-msvc": "15.5.19",
+        "@next/swc-win32-x64-msvc": "15.5.19",
+        "sharp": "^0.34.3"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.1.0",
+        "@playwright/test": "^1.51.1",
+        "babel-plugin-react-compiler": "*",
+        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "sass": "^1.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@playwright/test": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.4.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.6",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+      "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.7"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
+      "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
+      "license": "ISC",
+      "optional": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/styled-jsx": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+      "license": "MIT",
+      "dependencies": {
+        "client-only": "0.0.1"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "babel-plugin-macros": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}

+ 22 - 0
web2/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "content-find-agent-web2",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --hostname 127.0.0.1",
+    "lint": "tsc --noEmit",
+    "build": "next build"
+  },
+  "dependencies": {
+    "lucide-react": "^0.468.0",
+    "next": "^15.1.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.0",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "typescript": "^5.7.0"
+  }
+}

+ 23 - 0
web2/tsconfig.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "ES2017",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": false,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
+    "plugins": [{ "name": "next" }],
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}