JourneyApp.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { AlertTriangle, RefreshCw } from "lucide-react";
  4. import { fetchJourney, fetchRuns } from "@/lib/api";
  5. import { statusLabel } from "@/lib/journey";
  6. import type { JourneyStep, JourneyView, RunSummary, ZoomLevel } from "@/lib/types";
  7. import { EvidencePanel } from "@/components/EvidencePanel";
  8. import { JourneyCanvas } from "@/components/JourneyCanvas";
  9. const ZOOM_OPTIONS: Array<{ value: ZoomLevel; label: string; hint: string }> = [
  10. { value: "journey", label: "全局", hint: "看创作回合" },
  11. { value: "steps", label: "步骤", hint: "看规划与返工" },
  12. { value: "evidence", label: "证据", hint: "看原始记录" },
  13. ];
  14. const TERMINAL_STATUSES = new Set(["completed", "stopped", "failed", "published"]);
  15. export function JourneyApp() {
  16. const [runs, setRuns] = useState<RunSummary[]>([]);
  17. const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
  18. const [journey, setJourney] = useState<JourneyView | null>(null);
  19. const [zoom, setZoom] = useState<ZoomLevel>("journey");
  20. const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
  21. const [loading, setLoading] = useState(true);
  22. const [error, setError] = useState<string | null>(null);
  23. const [refreshTick, setRefreshTick] = useState(0);
  24. useEffect(() => {
  25. const controller = new AbortController();
  26. fetchRuns(controller.signal)
  27. .then((values) => {
  28. setRuns(values);
  29. setSelectedRunId((current) => current ?? values[0]?.script_build_id ?? null);
  30. })
  31. .catch((reason: Error) => setError(reason.message))
  32. .finally(() => setLoading(false));
  33. return () => controller.abort();
  34. }, []);
  35. const loadJourney = useCallback(
  36. async (signal?: AbortSignal) => {
  37. if (selectedRunId === null) return null;
  38. setError(null);
  39. try {
  40. const value = await fetchJourney(selectedRunId, signal);
  41. setJourney(value);
  42. setSelectedStepId((current) =>
  43. current && value.steps.some((step) => step.step_id === current)
  44. ? current
  45. : value.steps[0]?.step_id ?? null,
  46. );
  47. return value;
  48. } catch (reason) {
  49. if (!(reason instanceof DOMException && reason.name === "AbortError")) {
  50. setError(reason instanceof Error ? reason.message : "无法读取创作旅程");
  51. }
  52. return null;
  53. }
  54. },
  55. [selectedRunId],
  56. );
  57. useEffect(() => {
  58. const controller = new AbortController();
  59. let timer: number | null = null;
  60. async function refresh() {
  61. const value = await loadJourney(controller.signal);
  62. if (
  63. !controller.signal.aborted &&
  64. value &&
  65. !TERMINAL_STATUSES.has(value.status.toLowerCase())
  66. ) {
  67. timer = window.setTimeout(refresh, 12_000);
  68. }
  69. }
  70. setLoading(true);
  71. refresh().finally(() => setLoading(false));
  72. return () => {
  73. controller.abort();
  74. if (timer !== null) window.clearTimeout(timer);
  75. };
  76. }, [loadJourney, refreshTick]);
  77. const selectedStep = useMemo<JourneyStep | null>(
  78. () => journey?.steps.find((step) => step.step_id === selectedStepId) ?? null,
  79. [journey, selectedStepId],
  80. );
  81. const selectedRun = useMemo(
  82. () => runs.find((run) => run.script_build_id === selectedRunId) ?? null,
  83. [runs, selectedRunId],
  84. );
  85. if (!loading && runs.length === 0 && !error) {
  86. return (
  87. <main className="empty-state">
  88. <h1>还没有可展示的创作记录</h1>
  89. <p>完成一次 Script Build 后,这里会按真实执行顺序展示它的规划、执行、验收和返工。</p>
  90. </main>
  91. );
  92. }
  93. return (
  94. <main className={`journey-app zoom-${zoom}`}>
  95. <header className="app-bar">
  96. <div className="brand-lockup">
  97. <span className="brand-mark" aria-hidden="true" />
  98. <div>
  99. <strong>智能创作旅程</strong>
  100. <span>每一步都回到真实记录</span>
  101. </div>
  102. </div>
  103. <label className="run-picker">
  104. <span>本次创作</span>
  105. <select
  106. value={selectedRunId ?? ""}
  107. onChange={(event) => setSelectedRunId(Number(event.target.value))}
  108. >
  109. {runs.map((run) => (
  110. <option key={run.script_build_id} value={run.script_build_id}>
  111. #{run.script_build_id} · {run.summary}
  112. </option>
  113. ))}
  114. </select>
  115. </label>
  116. <div className="zoom-switch" aria-label="查看层级">
  117. {ZOOM_OPTIONS.map((option) => (
  118. <button
  119. key={option.value}
  120. type="button"
  121. className={zoom === option.value ? "active" : ""}
  122. aria-pressed={zoom === option.value}
  123. onClick={() => setZoom(option.value)}
  124. title={option.hint}
  125. >
  126. {option.label}
  127. </button>
  128. ))}
  129. </div>
  130. <button
  131. className="icon-button"
  132. type="button"
  133. onClick={() => setRefreshTick((value) => value + 1)}
  134. aria-label="刷新创作旅程"
  135. >
  136. <RefreshCw size={17} className={loading ? "is-spinning" : ""} />
  137. </button>
  138. </header>
  139. {error ? (
  140. <div className="error-banner" role="alert">
  141. <AlertTriangle size={16} />
  142. <span>{error}</span>
  143. <button type="button" onClick={() => setRefreshTick((value) => value + 1)}>
  144. 重试
  145. </button>
  146. </div>
  147. ) : null}
  148. {journey ? (
  149. <>
  150. <section className="mission-strip">
  151. <div className="mission-copy">
  152. <span className={`run-status status-${journey.status}`}>{statusLabel(journey.status)}</span>
  153. <h1>{journey.objective}</h1>
  154. </div>
  155. <div className="input-ribbon" aria-label="本次创作输入">
  156. <span className="ribbon-label">DATA IN</span>
  157. <strong>{journey.input_summary.topic}</strong>
  158. <span>{journey.input_summary.account_name ?? "未指定账号"}</span>
  159. <span>{journey.input_summary.persona_point_count} 条人设信息</span>
  160. <span>
  161. {journey.input_summary.strategy_names.length
  162. ? journey.input_summary.strategy_names.join("、")
  163. : "无额外策略"}
  164. </span>
  165. </div>
  166. </section>
  167. <div className="workspace">
  168. <JourneyCanvas
  169. journey={journey}
  170. zoom={zoom}
  171. selectedStepId={selectedStepId}
  172. onSelectStep={(stepId) => {
  173. setSelectedStepId(stepId);
  174. if (zoom === "journey") setZoom("steps");
  175. }}
  176. />
  177. {zoom === "evidence" ? (
  178. <EvidencePanel
  179. journey={journey}
  180. run={selectedRun}
  181. step={selectedStep}
  182. onClose={() => setZoom("steps")}
  183. />
  184. ) : null}
  185. </div>
  186. </>
  187. ) : (
  188. <div className="journey-skeleton" aria-label="正在读取创作旅程">
  189. <span />
  190. <span />
  191. <span />
  192. </div>
  193. )}
  194. </main>
  195. );
  196. }