|
|
@@ -0,0 +1,215 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
+import { AlertTriangle, RefreshCw } from "lucide-react";
|
|
|
+
|
|
|
+import { fetchJourney, fetchRuns } from "@/lib/api";
|
|
|
+import { statusLabel } from "@/lib/journey";
|
|
|
+import type { JourneyStep, JourneyView, RunSummary, ZoomLevel } from "@/lib/types";
|
|
|
+
|
|
|
+import { EvidencePanel } from "@/components/EvidencePanel";
|
|
|
+import { JourneyCanvas } from "@/components/JourneyCanvas";
|
|
|
+
|
|
|
+const ZOOM_OPTIONS: Array<{ value: ZoomLevel; label: string; hint: string }> = [
|
|
|
+ { value: "journey", label: "全局", hint: "看创作回合" },
|
|
|
+ { value: "steps", label: "步骤", hint: "看规划与返工" },
|
|
|
+ { value: "evidence", label: "证据", hint: "看原始记录" },
|
|
|
+];
|
|
|
+const TERMINAL_STATUSES = new Set(["completed", "stopped", "failed", "published"]);
|
|
|
+
|
|
|
+export function JourneyApp() {
|
|
|
+ const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
|
+ const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
|
|
|
+ const [journey, setJourney] = useState<JourneyView | null>(null);
|
|
|
+ const [zoom, setZoom] = useState<ZoomLevel>("journey");
|
|
|
+ const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
+ const [refreshTick, setRefreshTick] = useState(0);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const controller = new AbortController();
|
|
|
+ fetchRuns(controller.signal)
|
|
|
+ .then((values) => {
|
|
|
+ setRuns(values);
|
|
|
+ setSelectedRunId((current) => current ?? values[0]?.script_build_id ?? null);
|
|
|
+ })
|
|
|
+ .catch((reason: Error) => setError(reason.message))
|
|
|
+ .finally(() => setLoading(false));
|
|
|
+ return () => controller.abort();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const loadJourney = useCallback(
|
|
|
+ async (signal?: AbortSignal) => {
|
|
|
+ if (selectedRunId === null) return null;
|
|
|
+ setError(null);
|
|
|
+ try {
|
|
|
+ const value = await fetchJourney(selectedRunId, signal);
|
|
|
+ setJourney(value);
|
|
|
+ setSelectedStepId((current) =>
|
|
|
+ current && value.steps.some((step) => step.step_id === current)
|
|
|
+ ? current
|
|
|
+ : value.steps[0]?.step_id ?? null,
|
|
|
+ );
|
|
|
+ return value;
|
|
|
+ } catch (reason) {
|
|
|
+ if (!(reason instanceof DOMException && reason.name === "AbortError")) {
|
|
|
+ setError(reason instanceof Error ? reason.message : "无法读取创作旅程");
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [selectedRunId],
|
|
|
+ );
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const controller = new AbortController();
|
|
|
+ let timer: number | null = null;
|
|
|
+
|
|
|
+ async function refresh() {
|
|
|
+ const value = await loadJourney(controller.signal);
|
|
|
+ if (
|
|
|
+ !controller.signal.aborted &&
|
|
|
+ value &&
|
|
|
+ !TERMINAL_STATUSES.has(value.status.toLowerCase())
|
|
|
+ ) {
|
|
|
+ timer = window.setTimeout(refresh, 12_000);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ setLoading(true);
|
|
|
+ refresh().finally(() => setLoading(false));
|
|
|
+ return () => {
|
|
|
+ controller.abort();
|
|
|
+ if (timer !== null) window.clearTimeout(timer);
|
|
|
+ };
|
|
|
+ }, [loadJourney, refreshTick]);
|
|
|
+
|
|
|
+ const selectedStep = useMemo<JourneyStep | null>(
|
|
|
+ () => journey?.steps.find((step) => step.step_id === selectedStepId) ?? null,
|
|
|
+ [journey, selectedStepId],
|
|
|
+ );
|
|
|
+ const selectedRun = useMemo(
|
|
|
+ () => runs.find((run) => run.script_build_id === selectedRunId) ?? null,
|
|
|
+ [runs, selectedRunId],
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!loading && runs.length === 0 && !error) {
|
|
|
+ return (
|
|
|
+ <main className="empty-state">
|
|
|
+ <h1>还没有可展示的创作记录</h1>
|
|
|
+ <p>完成一次 Script Build 后,这里会按真实执行顺序展示它的规划、执行、验收和返工。</p>
|
|
|
+ </main>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <main className={`journey-app zoom-${zoom}`}>
|
|
|
+ <header className="app-bar">
|
|
|
+ <div className="brand-lockup">
|
|
|
+ <span className="brand-mark" aria-hidden="true" />
|
|
|
+ <div>
|
|
|
+ <strong>智能创作旅程</strong>
|
|
|
+ <span>每一步都回到真实记录</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <label className="run-picker">
|
|
|
+ <span>本次创作</span>
|
|
|
+ <select
|
|
|
+ value={selectedRunId ?? ""}
|
|
|
+ onChange={(event) => setSelectedRunId(Number(event.target.value))}
|
|
|
+ >
|
|
|
+ {runs.map((run) => (
|
|
|
+ <option key={run.script_build_id} value={run.script_build_id}>
|
|
|
+ #{run.script_build_id} · {run.summary}
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+
|
|
|
+ <div className="zoom-switch" aria-label="查看层级">
|
|
|
+ {ZOOM_OPTIONS.map((option) => (
|
|
|
+ <button
|
|
|
+ key={option.value}
|
|
|
+ type="button"
|
|
|
+ className={zoom === option.value ? "active" : ""}
|
|
|
+ aria-pressed={zoom === option.value}
|
|
|
+ onClick={() => setZoom(option.value)}
|
|
|
+ title={option.hint}
|
|
|
+ >
|
|
|
+ {option.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button
|
|
|
+ className="icon-button"
|
|
|
+ type="button"
|
|
|
+ onClick={() => setRefreshTick((value) => value + 1)}
|
|
|
+ aria-label="刷新创作旅程"
|
|
|
+ >
|
|
|
+ <RefreshCw size={17} className={loading ? "is-spinning" : ""} />
|
|
|
+ </button>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ {error ? (
|
|
|
+ <div className="error-banner" role="alert">
|
|
|
+ <AlertTriangle size={16} />
|
|
|
+ <span>{error}</span>
|
|
|
+ <button type="button" onClick={() => setRefreshTick((value) => value + 1)}>
|
|
|
+ 重试
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {journey ? (
|
|
|
+ <>
|
|
|
+ <section className="mission-strip">
|
|
|
+ <div className="mission-copy">
|
|
|
+ <span className={`run-status status-${journey.status}`}>{statusLabel(journey.status)}</span>
|
|
|
+ <h1>{journey.objective}</h1>
|
|
|
+ </div>
|
|
|
+ <div className="input-ribbon" aria-label="本次创作输入">
|
|
|
+ <span className="ribbon-label">DATA IN</span>
|
|
|
+ <strong>{journey.input_summary.topic}</strong>
|
|
|
+ <span>{journey.input_summary.account_name ?? "未指定账号"}</span>
|
|
|
+ <span>{journey.input_summary.persona_point_count} 条人设信息</span>
|
|
|
+ <span>
|
|
|
+ {journey.input_summary.strategy_names.length
|
|
|
+ ? journey.input_summary.strategy_names.join("、")
|
|
|
+ : "无额外策略"}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div className="workspace">
|
|
|
+ <JourneyCanvas
|
|
|
+ journey={journey}
|
|
|
+ zoom={zoom}
|
|
|
+ selectedStepId={selectedStepId}
|
|
|
+ onSelectStep={(stepId) => {
|
|
|
+ setSelectedStepId(stepId);
|
|
|
+ if (zoom === "journey") setZoom("steps");
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {zoom === "evidence" ? (
|
|
|
+ <EvidencePanel
|
|
|
+ journey={journey}
|
|
|
+ run={selectedRun}
|
|
|
+ step={selectedStep}
|
|
|
+ onClose={() => setZoom("steps")}
|
|
|
+ />
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <div className="journey-skeleton" aria-label="正在读取创作旅程">
|
|
|
+ <span />
|
|
|
+ <span />
|
|
|
+ <span />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </main>
|
|
|
+ );
|
|
|
+}
|