| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- "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>
- );
- }
|