fix(web): 链相位语义色/失败可重试 + 模板填入中文标签/删除可撤销/工具卡文案 + 模板库 PageHeader

This commit is contained in:
Yaojia Wang
2026-06-30 08:56:23 +02:00
parent 6bb3559e25
commit 164e98887e
10 changed files with 235 additions and 74 deletions

View File

@@ -1,14 +1,23 @@
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { TemplatesManager } from "@/components/templates/TemplatesManager"; import { TemplatesManager } from "@/components/templates/TemplatesManager";
import { fetchTemplates } from "@/lib/api/server"; import { PageHeader } from "@/components/ui/PageHeader";
import type { TemplateResponse } from "@/lib/api/types"; 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() { export default async function TemplatesPage() {
let initial: TemplateResponse[] = []; let initial: TemplateResponse[] = [];
let tools: ToolDescriptorView[] = [];
let loadError = false; let loadError = false;
try { try {
initial = await fetchTemplates(); const [templates, toolbox] = await Promise.all([
fetchTemplates(),
fetchToolbox(),
]);
initial = templates;
tools = toolbox.tools ?? [];
} catch { } catch {
loadError = true; loadError = true;
} }
@@ -16,15 +25,17 @@ export default async function TemplatesPage() {
return ( return (
<AppShell title="提示词 / 模板库"> <AppShell title="提示词 / 模板库">
<div className="mx-auto max-w-3xl px-8 py-10"> <div className="mx-auto max-w-3xl px-8 py-10">
<p className="mb-6 text-xs text-ink-soft"> <PageHeader
brief / title="提示词 / 模板库"
</p> eyebrow="prompt templates"
description="保存常用提示词,复用时一键填入生成器的需求 / 原文输入。仅本地、单用户,不做分享。"
/>
{loadError ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <StatusNote variant="danger" title="无法连接后端服务">
API <p> API </p>
</p> </StatusNote>
) : ( ) : (
<TemplatesManager initial={initial} /> <TemplatesManager initial={initial} tools={tools} />
)} )}
</div> </div>
</AppShell> </AppShell>

View File

@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { ConflictCard } from "@/components/review/ConflictCard"; import { ConflictCard } from "@/components/review/ConflictCard";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import { buildResumeRequest } from "@/lib/chain/chain"; import { buildResumeRequest } from "@/lib/chain/chain";
import { friendlyFromApiError } from "@/lib/errors/messages"; import { friendlyFromApiError } from "@/lib/errors/messages";
@@ -46,36 +47,44 @@ export function ChainAdjudication({
const [drafts, setDrafts] = useState<DecisionDraft[]>([]); const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
const [resuming, setResuming] = useState(false); const [resuming, setResuming] = useState(false);
// 拉取 awaiting 章最近审稿冲突。抽成可复用回调,供进场 effect 与「重新加载」按钮共用。
const load = useCallback(async (): Promise<boolean> => {
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(() => { useEffect(() => {
let active = true; let active = true;
setLoadState("loading");
void (async () => { void (async () => {
try { // 进场加载:组件已卸载则丢弃结果(避免 setState-after-unmount
const { data, error } = await api.GET( if (!active) return;
"/projects/{project_id}/chapters/{chapter_no}/reviews", await load();
{
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");
}
})(); })();
return () => { return () => {
active = false; active = false;
}; };
}, [projectId, chapterNo]); }, [load]);
const onVerdict = useCallback((index: number, verdict: Verdict): void => { const onVerdict = useCallback((index: number, verdict: Verdict): void => {
setDrafts((prev) => setVerdict(prev, index, verdict)); setDrafts((prev) => setVerdict(prev, index, verdict));
@@ -116,9 +125,17 @@ export function ChainAdjudication({
} }
if (loadState === "error") { if (loadState === "error") {
return ( return (
<p className="text-sm text-conflict"> <StatusNote variant="danger" title={`加载第 ${chapterNo} 章审稿冲突失败`}>
{chapterNo} 稿 <p></p>
</p> <Button
variant="outline"
size="sm"
onClick={() => void load()}
className="mt-3"
>
</Button>
</StatusNote>
); );
} }

View File

@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain"; import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
import { friendlyFromApiError } from "@/lib/errors/messages"; import { friendlyFromApiError } from "@/lib/errors/messages";
@@ -77,6 +79,11 @@ export function ChainPage({ project }: ChainPageProps) {
if (jobId) poll.poll(jobId); if (jobId) poll.poll(jobId);
}, [jobId, poll]); }, [jobId, poll]);
// 链失败重试:重启轮询同一 job覆盖轮询/网络抖动这类瞬时失败;图从检查点续)。
const onRetry = useCallback((): void => {
if (jobId) poll.poll(jobId);
}, [jobId, poll]);
const showProgress = jobId !== null && result !== null; const showProgress = jobId !== null && result !== null;
// 链运行中/等待裁决时禁止重复发起。 // 链运行中/等待裁决时禁止重复发起。
const busy = const busy =
@@ -112,13 +119,25 @@ export function ChainPage({ project }: ChainPageProps) {
) : null} ) : null}
{phase === "failed" ? ( {phase === "failed" ? (
<p className="text-sm text-conflict"> <StatusNote variant="danger" title="链运行失败">
{poll.error ?? "链运行失败,请重试。"} <p>{poll.error ?? "请稍后重试。"}</p>
</p> {jobId !== null ? (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="mt-3"
>
</Button>
) : null}
</StatusNote>
) : null} ) : null}
{phase === "completed" ? ( {phase === "completed" ? (
<p className="text-sm text-pass"></p> <StatusNote variant="success" title="本链已全部跑完">
<p></p>
</StatusNote>
) : null} ) : null}
</div> </div>
</AppShell> </AppShell>

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import type { BadgeVariant } from "@/lib/ui/variants";
import type { ChainPhase, ChainResultView } from "@/lib/chain/chain"; import type { ChainPhase, ChainResultView } from "@/lib/chain/chain";
interface ChainProgressProps { interface ChainProgressProps {
@@ -16,6 +18,14 @@ const PHASE_LABEL: Record<ChainPhase, string> = {
failed: "已失败", failed: "已失败",
}; };
// 相位 → 语义色:失败用危险色而非朱砂品牌色,运行/等待/完成各取信息/警示/成功。
const PHASE_VARIANT: Record<ChainPhase, BadgeVariant> = {
running: "info",
awaiting: "warning",
completed: "success",
failed: "danger",
};
// 链进度视图:相位徽标 + 进度条 + 已写章号清单token/正文绝不展示result 只含元数据)。 // 链进度视图:相位徽标 + 进度条 + 已写章号清单token/正文绝不展示result 只含元数据)。
export function ChainProgress({ phase, progress, result }: ChainProgressProps) { export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
return ( return (
@@ -26,23 +36,22 @@ export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h2 className="font-serif text-lg text-ink"></h2> <h2 className="font-serif text-lg text-ink"></h2>
<span <Badge variant={PHASE_VARIANT[phase]} aria-live="polite">
className="rounded bg-cinnabar/10 px-2 py-0.5 text-xs text-cinnabar"
aria-live="polite"
>
{PHASE_LABEL[phase]} {PHASE_LABEL[phase]}
</span> </Badge>
</div> </div>
<div <div
className="h-2 w-full overflow-hidden rounded bg-bg" className="h-2 w-full overflow-hidden rounded bg-bg"
role="progressbar" role="progressbar"
aria-label="链写章进度"
aria-valuenow={progress} aria-valuenow={progress}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-valuetext={`已完成 ${result.written.length}`}
> >
<div <div
className="h-full bg-cinnabar transition-all" className="h-full bg-cinnabar motion-safe:transition-all"
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>

View File

@@ -2,34 +2,42 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { FileText } from "lucide-react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { Select } from "@/components/ui/Select";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { TemplateResponse } from "@/lib/api/types"; import type { TemplateResponse, ToolDescriptorView } from "@/lib/api/types";
import { import {
buildTemplateCreateRequest, buildTemplateCreateRequest,
EMPTY_TEMPLATE_DRAFT, EMPTY_TEMPLATE_DRAFT,
isTemplateDraftValid, isTemplateDraftValid,
type TemplateDraft, type TemplateDraft,
} from "@/lib/templates/templates"; } from "@/lib/templates/templates";
import { toolKeyOptions } from "@/lib/toolbox/toolbox";
import { cardClass } from "@/lib/ui/variants"; import { cardClass } from "@/lib/ui/variants";
interface TemplatesManagerProps { interface TemplatesManagerProps {
// 初始模板列表Server Component 预取;失败给空数组 + loadError 文案)。 // 初始模板列表Server Component 预取;失败给空数组 + loadError 文案)。
initial: TemplateResponse[]; initial: TemplateResponse[];
// 工具描述符(供「关联工具」下拉:展示中文标题、值用 key。空 → 退化为「不关联」单选。
tools: ToolDescriptorView[];
} }
// 提示词/模板库管理F3单用户本地版列出 / 新建 / 删除。 // 提示词/模板库管理F3单用户本地版列出 / 新建 / 删除。
// 列表本地维护乐观更新失败回滚CRUD 走 OpenAPI 客户端。分享/市场不做(需多租户)。 // 列表本地维护乐观更新失败回滚CRUD 走 OpenAPI 客户端。分享/市场不做(需多租户)。
export function TemplatesManager({ initial }: TemplatesManagerProps) { export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
const toast = useToast(); const toast = useToast();
const [templates, setTemplates] = useState<TemplateResponse[]>(initial); const [templates, setTemplates] = useState<TemplateResponse[]>(initial);
const [draft, setDraft] = useState<TemplateDraft>(EMPTY_TEMPLATE_DRAFT); const [draft, setDraft] = useState<TemplateDraft>(EMPTY_TEMPLATE_DRAFT);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const toolOptions = toolKeyOptions(tools);
const setField = useCallback( const setField = useCallback(
(field: keyof TemplateDraft, value: string): void => { (field: keyof TemplateDraft, value: string): void => {
@@ -62,27 +70,54 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
} }
}, [draft, toast]); }, [draft, toast]);
// 撤销删除:以原模板内容重新入库(后端无恢复接口,新建即可;新 id 不影响用途)。
const restore = useCallback(
async (removed: TemplateResponse): Promise<void> => {
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( const onDelete = useCallback(
async (id: string): Promise<void> => { async (removed: TemplateResponse): Promise<void> => {
const prev = templates; const prev = templates;
// 乐观删除:先移除,失败回滚。 // 乐观删除:先移除,失败回滚。
setTemplates((cur) => cur.filter((t) => t.id !== id)); setTemplates((cur) => cur.filter((t) => t.id !== removed.id));
try { try {
const { error } = await api.DELETE("/templates/{template_id}", { const { error } = await api.DELETE("/templates/{template_id}", {
params: { path: { template_id: id } }, params: { path: { template_id: removed.id } },
}); });
if (error) { if (error) {
setTemplates(prev); setTemplates(prev);
toast("删除模板失败,请重试。", "error"); toast("删除模板失败,请重试。", "error");
return; return;
} }
toast("已删除模板。", "success"); // 成功后给「撤销」action第三参误删可一键以原内容重新入库。
toast(`已删除模板「${removed.title}」。`, "success", {
action: { label: "撤销", onClick: () => void restore(removed) },
});
} catch { } catch {
setTemplates(prev); setTemplates(prev);
toast("删除模板请求异常,请检查网络。", "error"); toast("删除模板请求异常,请检查网络。", "error");
} }
}, },
[templates, toast], [templates, toast, restore],
); );
return ( return (
@@ -121,13 +156,19 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
aria-label="分类" aria-label="分类"
/> />
</Field> </Field>
<Field label="关联生成器 key(可选)" className="w-full sm:w-48"> <Field label="关联工具(可选)" className="w-full sm:w-48">
<TextInput <Select
type="text"
value={draft.toolKey} value={draft.toolKey}
onChange={(e) => setField("toolKey", e.target.value)} onChange={(e) => setField("toolKey", e.target.value)}
aria-label="关联生成器 key" aria-label="关联工具"
/> >
<option value=""></option>
{toolOptions.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.title}
</option>
))}
</Select>
</Field> </Field>
</div> </div>
<Button <Button
@@ -143,7 +184,11 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
<section className="flex flex-col gap-3" aria-label="模板列表"> <section className="flex flex-col gap-3" aria-label="模板列表">
<h2 className="font-serif text-lg text-ink">{templates.length}</h2> <h2 className="font-serif text-lg text-ink">{templates.length}</h2>
{templates.length === 0 ? ( {templates.length === 0 ? (
<p className="text-sm text-ink-soft"></p> <EmptyState
icon={FileText}
title="还没有模板"
description="用上面的表单保存一段常用提示词,之后在生成器里就能一键填入需求 / 原文。"
/>
) : ( ) : (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{templates.map((t) => ( {templates.map((t) => (
@@ -164,7 +209,7 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
</div> </div>
<Button <Button
type="button" type="button"
onClick={() => void onDelete(t.id)} onClick={() => void onDelete(t)}
variant="danger" variant="danger"
size="sm" size="sm"
className="shrink-0" className="shrink-0"

View File

@@ -8,8 +8,10 @@ import { ConflictAdjudication } from "@/components/generation/ConflictAdjudicati
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types"; import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
import { import {
buildGenerateRequest, buildGenerateRequest,
@@ -50,11 +52,17 @@ export function GeneratorRunner({
setValues((prev) => ({ ...prev, [name]: value })); setValues((prev) => ({ ...prev, [name]: value }));
}, []); }, []);
// 「从模板填入」的目标字段brief / text无则不渲染 // 「从模板填入」的目标字段brief / text无则不渲染匹配按 name展示用中文 label。
const fillTarget = useMemo( const fillTarget = useMemo(
() => templateFillTarget(tool.input_fields), () => templateFillTarget(tool.input_fields),
[tool.input_fields], [tool.input_fields],
); );
const fillLabel = useMemo(
() =>
(tool.input_fields ?? []).find((f) => f.name === fillTarget)?.label ??
fillTarget,
[tool.input_fields, fillTarget],
);
const onGenerate = useCallback(async (): Promise<void> => { const onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values); const missing = missingRequiredFields(tool.input_fields, values);
@@ -150,7 +158,11 @@ export function GeneratorRunner({
void onGenerate(); void onGenerate();
}} }}
> >
<TemplateFiller targetField={fillTarget} onFill={setField} /> <TemplateFiller
targetField={fillTarget}
targetLabel={fillLabel}
onFill={setField}
/>
{(tool.input_fields ?? []).map((field) => ( {(tool.input_fields ?? []).map((field) => (
<FormField <FormField
key={field.name} key={field.name}
@@ -163,6 +175,12 @@ export function GeneratorRunner({
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"} {generating ? "生成中…" : "生成"}
</Button> </Button>
{generating ? (
<ThinkingIndicator
label="生成中,长文可能需要十几秒…"
className="text-cinnabar"
/>
) : null}
</form> </form>
{gen.genStatus === "preview" && gen.preview ? ( {gen.genStatus === "preview" && gen.preview ? (
@@ -213,9 +231,9 @@ export function GeneratorRunner({
) : null} ) : null}
{gen.ingestStatus === "done" ? ( {gen.ingestStatus === "done" ? (
<p className="text-sm text-ink-soft"> <StatusNote variant="success" title={`已入库 ${gen.created.length}`}>
{gen.created.length} <p></p>
</p> </StatusNote>
) : null} ) : null}
</div> </div>
) : null} ) : null}

View File

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

View File

@@ -4,6 +4,7 @@ import { ArrowRight, Database, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import type { ToolDescriptorView } from "@/lib/api/types"; import type { ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool } from "@/lib/toolbox/toolbox";
import { cardClass } from "@/lib/ui/variants"; import { cardClass } from "@/lib/ui/variants";
interface ToolCardProps { interface ToolCardProps {
@@ -15,12 +16,17 @@ interface ToolCardProps {
// 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。 // 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。
// 纯展示;点击交由 ToolboxPage 分派legacy 跳转 / 新工具开 runner // 纯展示;点击交由 ToolboxPage 分派legacy 跳转 / 新工具开 runner
export function ToolCard({ tool, onOpen }: ToolCardProps) { export function ToolCard({ tool, onOpen }: ToolCardProps) {
const legacy = isLegacyTool(tool);
// legacy 工具跳现有页面;新工具进结构化预览流,提示语据此分支(不暴露内部 key
const hint = legacy
? "前往现有页面"
: `${tool.input_fields?.[0]?.label ?? "需求"} → 结构化预览`;
return ( return (
<button <button
type="button" type="button"
onClick={() => onOpen(tool)} onClick={() => onOpen(tool)}
className={cardClass( className={cardClass(
"group flex h-full min-h-[164px] flex-col p-5 text-left transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30", "group flex h-full min-h-[164px] flex-col p-5 text-left motion-safe:transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30",
)} )}
> >
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
@@ -37,11 +43,9 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p> <p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
<div className="mt-auto flex items-center gap-2 pt-4 text-xs text-ink-soft"> <div className="mt-auto flex items-center gap-2 pt-4 text-xs text-ink-soft">
<Sparkles className="h-3.5 w-3.5 text-cinnabar" aria-hidden="true" /> <Sparkles className="h-3.5 w-3.5 text-cinnabar" aria-hidden="true" />
<span className="truncate"> <span className="truncate">{hint}</span>
{tool.input_fields?.[0]?.label ?? "现有页面"}
</span>
<ArrowRight <ArrowRight
className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 transition-opacity group-hover:opacity-100" className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 motion-safe:transition-opacity group-hover:opacity-100"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>

View File

@@ -10,6 +10,7 @@ import {
previewRows, previewRows,
resolveLegacyRoute, resolveLegacyRoute,
templateFillTarget, templateFillTarget,
toolKeyOptions,
} from "./toolbox"; } from "./toolbox";
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({ const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
@@ -210,6 +211,22 @@ describe("isLegacyTool", () => {
isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })), isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })),
).toBe(false); ).toBe(false);
}); });
it("maps descriptors to key/title options", () => {
expect(
toolKeyOptions([
tool({ key: "idea", title: "脑洞生成器" }),
tool({ key: "title", title: "书名生成器" }),
]),
).toEqual([
{ key: "idea", title: "脑洞生成器" },
{ key: "title", title: "书名生成器" },
]);
});
it("returns empty array for undefined tools", () => {
expect(toolKeyOptions(undefined)).toEqual([]);
});
}); });
describe("templateFillTarget", () => { describe("templateFillTarget", () => {

View File

@@ -320,3 +320,17 @@ export function resolveLegacyRoute(
export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean { export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean {
return tool.is_legacy && !!tool.legacy_route; return tool.is_legacy && !!tool.legacy_route;
} }
// —— 「关联工具」下拉选项 ——
// 工具描述符 → 下拉选项value=key 用于 tool_key 关联label=中文标题供展示,不暴露英文 key
// 模板库「关联生成器」字段据此从纯文本框升级为 Select。
export interface ToolKeyOption {
key: string;
title: string;
}
export function toolKeyOptions(
tools: readonly ToolDescriptorView[] | undefined,
): ToolKeyOption[] {
return (tools ?? []).map((tool) => ({ key: tool.key, title: tool.title }));
}