Files
writer-work-flow/apps/web/components/templates/TemplatesManager.tsx
Yaojia Wang 67e30a6863 feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)
- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) +
  元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷
- EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容)
- loading 骨架化(Skeleton 拼作品库结构,motion-safe)
- 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot
- 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
2026-07-11 07:36:32 +02:00

228 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
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, 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, 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 => {
setDraft((prev) => ({ ...prev, [field]: value }));
},
[],
);
const onCreate = useCallback(async (): Promise<void> => {
if (!isTemplateDraftValid(draft)) {
toast("请填写标题与正文。", "error");
return;
}
setCreating(true);
try {
const { data, error } = await api.POST("/templates", {
body: buildTemplateCreateRequest(draft),
});
if (error || !data) {
toast("新建模板失败,请重试。", "error");
return;
}
setTemplates((prev) => [data, ...prev]);
setDraft(EMPTY_TEMPLATE_DRAFT);
toast("已新建模板。", "success");
} catch {
toast("新建模板请求异常,请检查网络。", "error");
} finally {
setCreating(false);
}
}, [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 (removed: TemplateResponse): Promise<void> => {
const prev = templates;
// 乐观删除:先移除,失败回滚。
setTemplates((cur) => cur.filter((t) => t.id !== removed.id));
try {
const { error } = await api.DELETE("/templates/{template_id}", {
params: { path: { template_id: removed.id } },
});
if (error) {
setTemplates(prev);
toast("删除模板失败,请重试。", "error");
return;
}
// 成功后给「撤销」action第三参误删可一键以原内容重新入库。
toast(`已删除模板「${removed.title}」。`, "success", {
action: { label: "撤销", onClick: () => void restore(removed) },
});
} catch {
setTemplates(prev);
toast("删除模板请求异常,请检查网络。", "error");
}
},
[templates, toast, restore],
);
return (
<div className="flex flex-col gap-6">
<form
className={cardClass("flex flex-col gap-3 p-5")}
aria-label="新建模板"
onSubmit={(e) => {
e.preventDefault();
void onCreate();
}}
>
<SectionHeader title="新建模板" />
<Field label="标题" required>
<TextInput
type="text"
value={draft.title}
onChange={(e) => setField("title", e.target.value)}
aria-label="标题"
/>
</Field>
<Field label="正文" required help="一键填入生成器的 brief/原文。">
<TextArea
value={draft.body}
onChange={(e) => setField("body", e.target.value)}
rows={4}
aria-label="正文"
/>
</Field>
<div className="flex flex-wrap gap-4">
<Field label="分类(可选)" className="w-full sm:w-48">
<TextInput
type="text"
value={draft.category}
onChange={(e) => setField("category", e.target.value)}
aria-label="分类"
/>
</Field>
<Field label="关联工具(可选)" className="w-full sm:w-48">
<Select
value={draft.toolKey}
onChange={(e) => setField("toolKey", e.target.value)}
aria-label="关联工具"
>
<option value=""></option>
{toolOptions.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.title}
</option>
))}
</Select>
</Field>
</div>
<Button
type="submit"
disabled={creating || !isTemplateDraftValid(draft)}
variant="primary"
className="self-start"
>
{creating ? "新建中…" : "新建模板"}
</Button>
</form>
<section className="flex flex-col gap-3" aria-label="模板列表">
<h2 className="font-serif text-title-md text-ink">{templates.length}</h2>
{templates.length === 0 ? (
<EmptyState
icon={FileText}
title="还没有模板"
description="用上面的表单保存一段常用提示词,之后在生成器里就能一键填入需求 / 原文。"
/>
) : (
<ul className="flex flex-col gap-2">
{templates.map((t) => (
<li
key={t.id}
className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg p-3 text-sm"
>
<div className="min-w-0 flex-1">
<p className="font-serif text-base text-ink">{t.title}</p>
{t.category || t.tool_key ? (
<p className="text-xs text-ink-soft">
{[t.category, t.tool_key].filter(Boolean).join(" · ")}
</p>
) : null}
<p className="mt-1 whitespace-pre-wrap text-ink-soft">
{t.body}
</p>
</div>
<Button
type="button"
onClick={() => void onDelete(t)}
variant="danger"
size="sm"
className="shrink-0"
aria-label={`删除模板 ${t.title}`}
>
</Button>
</li>
))}
</ul>
)}
</section>
</div>
);
}