From 164e98887ebcf4083c9a0cfbf9dc2cf53014c4f2 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 30 Jun 2026 08:56:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E9=93=BE=E7=9B=B8=E4=BD=8D?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E8=89=B2/=E5=A4=B1=E8=B4=A5=E5=8F=AF?= =?UTF-8?q?=E9=87=8D=E8=AF=95=20+=20=E6=A8=A1=E6=9D=BF=E5=A1=AB=E5=85=A5?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=A0=87=E7=AD=BE/=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=8F=AF=E6=92=A4=E9=94=80/=E5=B7=A5=E5=85=B7=E5=8D=A1?= =?UTF-8?q?=E6=96=87=E6=A1=88=20+=20=E6=A8=A1=E6=9D=BF=E5=BA=93=20PageHead?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/templates/page.tsx | 33 ++++++--- .../components/chain/ChainAdjudication.tsx | 69 +++++++++++------- apps/web/components/chain/ChainPage.tsx | 27 ++++++- apps/web/components/chain/ChainProgress.tsx | 21 ++++-- .../components/templates/TemplatesManager.tsx | 73 +++++++++++++++---- .../components/toolbox/GeneratorRunner.tsx | 28 +++++-- .../web/components/toolbox/TemplateFiller.tsx | 13 +++- apps/web/components/toolbox/ToolCard.tsx | 14 ++-- apps/web/lib/toolbox/toolbox.test.ts | 17 +++++ apps/web/lib/toolbox/toolbox.ts | 14 ++++ 10 files changed, 235 insertions(+), 74 deletions(-) diff --git a/apps/web/app/templates/page.tsx b/apps/web/app/templates/page.tsx index 596d083..95512b4 100644 --- a/apps/web/app/templates/page.tsx +++ b/apps/web/app/templates/page.tsx @@ -1,14 +1,23 @@ import { AppShell } from "@/components/AppShell"; import { TemplatesManager } from "@/components/templates/TemplatesManager"; -import { fetchTemplates } from "@/lib/api/server"; -import type { TemplateResponse } from "@/lib/api/types"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { StatusNote } from "@/components/ui/StatusNote"; +import { fetchTemplates, fetchToolbox } from "@/lib/api/server"; +import type { TemplateResponse, ToolDescriptorView } from "@/lib/api/types"; -// 提示词/模板库(F3,全局单用户本地版)。Server Component 预取列表;CRUD 在客户端组件。 +// 提示词/模板库(F3,全局单用户本地版)。Server Component 预取列表 + 工具描述符(供「关联工具」下拉); +// CRUD 在客户端组件。fetchToolbox 任何错误内部降级为空列表,不阻塞进页。 export default async function TemplatesPage() { let initial: TemplateResponse[] = []; + let tools: ToolDescriptorView[] = []; let loadError = false; try { - initial = await fetchTemplates(); + const [templates, toolbox] = await Promise.all([ + fetchTemplates(), + fetchToolbox(), + ]); + initial = templates; + tools = toolbox.tools ?? []; } catch { loadError = true; } @@ -16,15 +25,17 @@ export default async function TemplatesPage() { return (
-

- 保存常用提示词,复用时一键填入生成器的 brief / 原文输入。仅本地、单用户,不做分享。 -

+ {loadError ? ( -

- 无法连接后端服务,请确认 API 已启动后刷新。 -

+ +

请确认 API 已启动后刷新页面。

+
) : ( - + )}
diff --git a/apps/web/components/chain/ChainAdjudication.tsx b/apps/web/components/chain/ChainAdjudication.tsx index abed663..7545924 100644 --- a/apps/web/components/chain/ChainAdjudication.tsx +++ b/apps/web/components/chain/ChainAdjudication.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react"; import { ConflictCard } from "@/components/review/ConflictCard"; import { useToast } from "@/components/Toast"; import { Button } from "@/components/ui/Button"; +import { StatusNote } from "@/components/ui/StatusNote"; import { api } from "@/lib/api/client"; import { buildResumeRequest } from "@/lib/chain/chain"; import { friendlyFromApiError } from "@/lib/errors/messages"; @@ -46,36 +47,44 @@ export function ChainAdjudication({ const [drafts, setDrafts] = useState([]); const [resuming, setResuming] = useState(false); + // 拉取 awaiting 章最近审稿冲突。抽成可复用回调,供进场 effect 与「重新加载」按钮共用。 + const load = useCallback(async (): Promise => { + setLoadState("loading"); + try { + const { data, error } = await api.GET( + "/projects/{project_id}/chapters/{chapter_no}/reviews", + { + params: { + path: { project_id: projectId, chapter_no: chapterNo }, + }, + }, + ); + if (error || !data) { + setLoadState("error"); + return false; + } + const next = normalizeConflicts(latestReview(data.reviews)); + setConflicts(next); + setDrafts(emptyDecisions(next.length)); + setLoadState("ready"); + return true; + } catch { + setLoadState("error"); + return false; + } + }, [projectId, chapterNo]); + useEffect(() => { let active = true; - setLoadState("loading"); void (async () => { - try { - const { data, error } = await api.GET( - "/projects/{project_id}/chapters/{chapter_no}/reviews", - { - params: { - path: { project_id: projectId, chapter_no: chapterNo }, - }, - }, - ); - if (!active) return; - if (error || !data) { - setLoadState("error"); - return; - } - const next = normalizeConflicts(latestReview(data.reviews)); - setConflicts(next); - setDrafts(emptyDecisions(next.length)); - setLoadState("ready"); - } catch { - if (active) setLoadState("error"); - } + // 进场加载:组件已卸载则丢弃结果(避免 setState-after-unmount)。 + if (!active) return; + await load(); })(); return () => { active = false; }; - }, [projectId, chapterNo]); + }, [load]); const onVerdict = useCallback((index: number, verdict: Verdict): void => { setDrafts((prev) => setVerdict(prev, index, verdict)); @@ -116,9 +125,17 @@ export function ChainAdjudication({ } if (loadState === "error") { return ( -

- 加载第 {chapterNo} 章审稿冲突失败,请刷新重试。 -

+ +

请检查网络后重新加载。

+ +
); } diff --git a/apps/web/components/chain/ChainPage.tsx b/apps/web/components/chain/ChainPage.tsx index d102b29..cadf14d 100644 --- a/apps/web/components/chain/ChainPage.tsx +++ b/apps/web/components/chain/ChainPage.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { useToast } from "@/components/Toast"; +import { Button } from "@/components/ui/Button"; +import { StatusNote } from "@/components/ui/StatusNote"; import { api } from "@/lib/api/client"; import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain"; import { friendlyFromApiError } from "@/lib/errors/messages"; @@ -77,6 +79,11 @@ export function ChainPage({ project }: ChainPageProps) { if (jobId) poll.poll(jobId); }, [jobId, poll]); + // 链失败重试:重启轮询同一 job(覆盖轮询/网络抖动这类瞬时失败;图从检查点续)。 + const onRetry = useCallback((): void => { + if (jobId) poll.poll(jobId); + }, [jobId, poll]); + const showProgress = jobId !== null && result !== null; // 链运行中/等待裁决时禁止重复发起。 const busy = @@ -112,13 +119,25 @@ export function ChainPage({ project }: ChainPageProps) { ) : null} {phase === "failed" ? ( -

- {poll.error ?? "链运行失败,请重试。"} -

+ +

{poll.error ?? "请稍后重试。"}

+ {jobId !== null ? ( + + ) : null} +
) : null} {phase === "completed" ? ( -

本链已全部跑完。

+ +

各章已写完并验收,可在大纲或正文里继续推进。

+
) : null} diff --git a/apps/web/components/chain/ChainProgress.tsx b/apps/web/components/chain/ChainProgress.tsx index 480db14..ae4b970 100644 --- a/apps/web/components/chain/ChainProgress.tsx +++ b/apps/web/components/chain/ChainProgress.tsx @@ -1,6 +1,8 @@ "use client"; +import { Badge } from "@/components/ui/Badge"; import { Card } from "@/components/ui/Card"; +import type { BadgeVariant } from "@/lib/ui/variants"; import type { ChainPhase, ChainResultView } from "@/lib/chain/chain"; interface ChainProgressProps { @@ -16,6 +18,14 @@ const PHASE_LABEL: Record = { failed: "已失败", }; +// 相位 → 语义色:失败用危险色而非朱砂品牌色,运行/等待/完成各取信息/警示/成功。 +const PHASE_VARIANT: Record = { + running: "info", + awaiting: "warning", + completed: "success", + failed: "danger", +}; + // 链进度视图:相位徽标 + 进度条 + 已写章号清单(token/正文绝不展示,result 只含元数据)。 export function ChainProgress({ phase, progress, result }: ChainProgressProps) { return ( @@ -26,23 +36,22 @@ export function ChainProgress({ phase, progress, result }: ChainProgressProps) { >

链进度

- + {PHASE_LABEL[phase]} - +
diff --git a/apps/web/components/templates/TemplatesManager.tsx b/apps/web/components/templates/TemplatesManager.tsx index f1d0492..a0687b2 100644 --- a/apps/web/components/templates/TemplatesManager.tsx +++ b/apps/web/components/templates/TemplatesManager.tsx @@ -2,34 +2,42 @@ import { useCallback, useState } from "react"; +import { FileText } from "lucide-react"; + import { useToast } from "@/components/Toast"; import { Button } from "@/components/ui/Button"; +import { EmptyState } from "@/components/ui/EmptyState"; import { Field } from "@/components/ui/Field"; import { SectionHeader } from "@/components/ui/SectionHeader"; +import { Select } from "@/components/ui/Select"; import { TextArea } from "@/components/ui/TextArea"; import { TextInput } from "@/components/ui/TextInput"; import { api } from "@/lib/api/client"; -import type { TemplateResponse } from "@/lib/api/types"; +import type { TemplateResponse, ToolDescriptorView } from "@/lib/api/types"; import { buildTemplateCreateRequest, EMPTY_TEMPLATE_DRAFT, isTemplateDraftValid, type TemplateDraft, } from "@/lib/templates/templates"; +import { toolKeyOptions } from "@/lib/toolbox/toolbox"; import { cardClass } from "@/lib/ui/variants"; interface TemplatesManagerProps { // 初始模板列表(Server Component 预取;失败给空数组 + loadError 文案)。 initial: TemplateResponse[]; + // 工具描述符(供「关联工具」下拉:展示中文标题、值用 key)。空 → 退化为「不关联」单选。 + tools: ToolDescriptorView[]; } // 提示词/模板库管理(F3,单用户本地版):列出 / 新建 / 删除。 // 列表本地维护(乐观更新失败回滚);CRUD 走 OpenAPI 客户端。分享/市场不做(需多租户)。 -export function TemplatesManager({ initial }: TemplatesManagerProps) { +export function TemplatesManager({ initial, tools }: TemplatesManagerProps) { const toast = useToast(); const [templates, setTemplates] = useState(initial); const [draft, setDraft] = useState(EMPTY_TEMPLATE_DRAFT); const [creating, setCreating] = useState(false); + const toolOptions = toolKeyOptions(tools); const setField = useCallback( (field: keyof TemplateDraft, value: string): void => { @@ -62,27 +70,54 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) { } }, [draft, toast]); + // 撤销删除:以原模板内容重新入库(后端无恢复接口,新建即可;新 id 不影响用途)。 + const restore = useCallback( + async (removed: TemplateResponse): Promise => { + try { + const { data, error } = await api.POST("/templates", { + body: { + title: removed.title, + body: removed.body, + category: removed.category ?? null, + tool_key: removed.tool_key ?? null, + }, + }); + if (error || !data) { + toast("撤销失败,请重新新建。", "error"); + return; + } + setTemplates((cur) => [data, ...cur]); + } catch { + toast("撤销请求异常,请检查网络。", "error"); + } + }, + [toast], + ); + const onDelete = useCallback( - async (id: string): Promise => { + async (removed: TemplateResponse): Promise => { const prev = templates; // 乐观删除:先移除,失败回滚。 - setTemplates((cur) => cur.filter((t) => t.id !== id)); + setTemplates((cur) => cur.filter((t) => t.id !== removed.id)); try { const { error } = await api.DELETE("/templates/{template_id}", { - params: { path: { template_id: id } }, + params: { path: { template_id: removed.id } }, }); if (error) { setTemplates(prev); toast("删除模板失败,请重试。", "error"); return; } - toast("已删除模板。", "success"); + // 成功后给「撤销」action(第三参):误删可一键以原内容重新入库。 + toast(`已删除模板「${removed.title}」。`, "success", { + action: { label: "撤销", onClick: () => void restore(removed) }, + }); } catch { setTemplates(prev); toast("删除模板请求异常,请检查网络。", "error"); } }, - [templates, toast], + [templates, toast, restore], ); return ( @@ -121,13 +156,19 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) { aria-label="分类" /> - - +
+ {generating ? ( + + ) : null} {gen.genStatus === "preview" && gen.preview ? ( @@ -213,9 +231,9 @@ export function GeneratorRunner({ ) : null} {gen.ingestStatus === "done" ? ( -

- 已入库 {gen.created.length} 项。 -

+ +

内容已写入设定库,可在对应页面查看与续编。

+
) : null} ) : null} diff --git a/apps/web/components/toolbox/TemplateFiller.tsx b/apps/web/components/toolbox/TemplateFiller.tsx index 88db071..d3b3b00 100644 --- a/apps/web/components/toolbox/TemplateFiller.tsx +++ b/apps/web/components/toolbox/TemplateFiller.tsx @@ -9,15 +9,21 @@ import type { TemplateResponse } from "@/lib/api/types"; import { buttonClass } from "@/lib/ui/variants"; interface TemplateFillerProps { - // 可填入的目标输入字段名(如 brief / text);空 → 不渲染(无可填字段)。 + // 可填入的目标输入字段名(如 brief / text);空 → 不渲染(无可填字段)。匹配/回写按此 name。 targetField: string | null; + // 目标字段的中文展示名(如「需求」「原文」);缺省回退到 name。仅用于展示,不暴露英文 key。 + targetLabel?: string | null; // 选定模板 → 把其 body 填进目标字段(纯前端,不改生成器后端)。 onFill: (field: string, body: string) => void; } // 「从模板填入」(F3,纯前端):进场懒加载模板库,选一个把 body 填进生成器的 brief/原文输入。 // 与生成器后端解耦——只读 /templates 并回写本地表单值。无模板/无目标字段则不渲染。 -export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) { +export function TemplateFiller({ + targetField, + targetLabel, + onFill, +}: TemplateFillerProps) { const toast = useToast(); const [templates, setTemplates] = useState(null); const [open, setOpen] = useState(false); @@ -43,6 +49,7 @@ export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) { if (!targetField) return null; const field = targetField; + const fieldLabel = targetLabel ?? targetField; if (!open) { return ( @@ -63,7 +70,7 @@ export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) { aria-label="从模板填入" >
- 选一个模板填入「{field}」 + 选一个模板填入「{fieldLabel}」