fix(web): 链相位语义色/失败可重试 + 模板填入中文标签/删除可撤销/工具卡文案 + 模板库 PageHeader
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user