fix(web): 链相位语义色/失败可重试 + 模板填入中文标签/删除可撤销/工具卡文案 + 模板库 PageHeader
This commit is contained in:
@@ -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 (
|
||||
<AppShell title="提示词 / 模板库">
|
||||
<div className="mx-auto max-w-3xl px-8 py-10">
|
||||
<p className="mb-6 text-xs text-ink-soft">
|
||||
保存常用提示词,复用时一键填入生成器的 brief / 原文输入。仅本地、单用户,不做分享。
|
||||
</p>
|
||||
<PageHeader
|
||||
title="提示词 / 模板库"
|
||||
eyebrow="prompt templates"
|
||||
description="保存常用提示词,复用时一键填入生成器的需求 / 原文输入。仅本地、单用户,不做分享。"
|
||||
/>
|
||||
{loadError ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
<StatusNote variant="danger" title="无法连接后端服务">
|
||||
<p>请确认 API 已启动后刷新页面。</p>
|
||||
</StatusNote>
|
||||
) : (
|
||||
<TemplatesManager initial={initial} />
|
||||
<TemplatesManager initial={initial} tools={tools} />
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -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<DecisionDraft[]>([]);
|
||||
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(() => {
|
||||
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 (
|
||||
<p className="text-sm text-conflict">
|
||||
加载第 {chapterNo} 章审稿冲突失败,请刷新重试。
|
||||
</p>
|
||||
<StatusNote variant="danger" title={`加载第 ${chapterNo} 章审稿冲突失败`}>
|
||||
<p>请检查网络后重新加载。</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
className="mt-3"
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</StatusNote>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" ? (
|
||||
<p className="text-sm text-conflict">
|
||||
{poll.error ?? "链运行失败,请重试。"}
|
||||
</p>
|
||||
<StatusNote variant="danger" title="链运行失败">
|
||||
<p>{poll.error ?? "请稍后重试。"}</p>
|
||||
{jobId !== null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="mt-3"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</StatusNote>
|
||||
) : null}
|
||||
|
||||
{phase === "completed" ? (
|
||||
<p className="text-sm text-pass">本链已全部跑完。</p>
|
||||
<StatusNote variant="success" title="本链已全部跑完">
|
||||
<p>各章已写完并验收,可在大纲或正文里继续推进。</p>
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -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<ChainPhase, string> = {
|
||||
failed: "已失败",
|
||||
};
|
||||
|
||||
// 相位 → 语义色:失败用危险色而非朱砂品牌色,运行/等待/完成各取信息/警示/成功。
|
||||
const PHASE_VARIANT: Record<ChainPhase, BadgeVariant> = {
|
||||
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) {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-serif text-lg text-ink">链进度</h2>
|
||||
<span
|
||||
className="rounded bg-cinnabar/10 px-2 py-0.5 text-xs text-cinnabar"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Badge variant={PHASE_VARIANT[phase]} aria-live="polite">
|
||||
{PHASE_LABEL[phase]}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2 w-full overflow-hidden rounded bg-bg"
|
||||
role="progressbar"
|
||||
aria-label="链写章进度"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={`已完成 ${result.written.length} 章`}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-cinnabar transition-all"
|
||||
className="h-full bg-cinnabar motion-safe:transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<TemplateResponse[]>(initial);
|
||||
const [draft, setDraft] = useState<TemplateDraft>(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<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(
|
||||
async (id: string): Promise<void> => {
|
||||
async (removed: TemplateResponse): Promise<void> => {
|
||||
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="分类"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="关联生成器 key(可选)" className="w-full sm:w-48">
|
||||
<TextInput
|
||||
type="text"
|
||||
<Field label="关联工具(可选)" className="w-full sm:w-48">
|
||||
<Select
|
||||
value={draft.toolKey}
|
||||
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>
|
||||
</div>
|
||||
<Button
|
||||
@@ -143,7 +184,11 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
<section className="flex flex-col gap-3" aria-label="模板列表">
|
||||
<h2 className="font-serif text-lg text-ink">已存模板({templates.length})</h2>
|
||||
{templates.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">(暂无模板,先新建一个吧。)</p>
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="还没有模板"
|
||||
description="用上面的表单保存一段常用提示词,之后在生成器里就能一键填入需求 / 原文。"
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{templates.map((t) => (
|
||||
@@ -164,7 +209,7 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onDelete(t.id)}
|
||||
onClick={() => void onDelete(t)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
|
||||
@@ -8,8 +8,10 @@ import { ConflictAdjudication } from "@/components/generation/ConflictAdjudicati
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
@@ -50,11 +52,17 @@ export function GeneratorRunner({
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
// 「从模板填入」的目标字段(brief / text;无则不渲染)。
|
||||
// 「从模板填入」的目标字段(brief / text;无则不渲染)。匹配按 name,展示用中文 label。
|
||||
const fillTarget = useMemo(
|
||||
() => templateFillTarget(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 missing = missingRequiredFields(tool.input_fields, values);
|
||||
@@ -150,7 +158,11 @@ export function GeneratorRunner({
|
||||
void onGenerate();
|
||||
}}
|
||||
>
|
||||
<TemplateFiller targetField={fillTarget} onFill={setField} />
|
||||
<TemplateFiller
|
||||
targetField={fillTarget}
|
||||
targetLabel={fillLabel}
|
||||
onFill={setField}
|
||||
/>
|
||||
{(tool.input_fields ?? []).map((field) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
@@ -163,6 +175,12 @@ export function GeneratorRunner({
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</Button>
|
||||
{generating ? (
|
||||
<ThinkingIndicator
|
||||
label="生成中,长文可能需要十几秒…"
|
||||
className="text-cinnabar"
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{gen.genStatus === "preview" && gen.preview ? (
|
||||
@@ -213,9 +231,9 @@ export function GeneratorRunner({
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "done" ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
已入库 {gen.created.length} 项。
|
||||
</p>
|
||||
<StatusNote variant="success" title={`已入库 ${gen.created.length} 项`}>
|
||||
<p>内容已写入设定库,可在对应页面查看与续编。</p>
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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<TemplateResponse[] | null>(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="从模板填入"
|
||||
>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowRight, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||
import { isLegacyTool } from "@/lib/toolbox/toolbox";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ToolCardProps {
|
||||
@@ -15,12 +16,17 @@ interface ToolCardProps {
|
||||
// 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。
|
||||
// 纯展示;点击交由 ToolboxPage 分派(legacy 跳转 / 新工具开 runner)。
|
||||
export function ToolCard({ tool, onOpen }: ToolCardProps) {
|
||||
const legacy = isLegacyTool(tool);
|
||||
// legacy 工具跳现有页面;新工具进结构化预览流,提示语据此分支(不暴露内部 key)。
|
||||
const hint = legacy
|
||||
? "前往现有页面"
|
||||
: `${tool.input_fields?.[0]?.label ?? "需求"} → 结构化预览`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(tool)}
|
||||
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">
|
||||
@@ -37,11 +43,9 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
|
||||
<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">
|
||||
<Sparkles className="h-3.5 w-3.5 text-cinnabar" aria-hidden="true" />
|
||||
<span className="truncate">
|
||||
{tool.input_fields?.[0]?.label ?? "现有页面"} → 结构化预览
|
||||
</span>
|
||||
<span className="truncate">{hint}</span>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
previewRows,
|
||||
resolveLegacyRoute,
|
||||
templateFillTarget,
|
||||
toolKeyOptions,
|
||||
} from "./toolbox";
|
||||
|
||||
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
|
||||
@@ -210,6 +211,22 @@ describe("isLegacyTool", () => {
|
||||
isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })),
|
||||
).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", () => {
|
||||
|
||||
@@ -320,3 +320,17 @@ export function resolveLegacyRoute(
|
||||
export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean {
|
||||
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 }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user