日志比较

This commit is contained in:
董宏凯 committed 2026-08-07 11:12:40 +08:00
1 parent aa3742b103
commit c555b92089
13 files changed
+1702 -1

No files matched your search

@@ -5,6 +5,7 @@ import {
ChevronLeft,
ChevronRight,
ChevronsUpDown,
FileText,
Folder,
FolderOpen,
Gauge,
@@ -236,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/notification-templates")}
>
<FileText className="size-4" />
通知模板管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/position-roles")}
@@ -549,6 +558,12 @@ function LegacyAccordionSidebar({
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/notification-templates")}>
<FileText className="size-4" />
通知模板管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" />
@@ -0,0 +1,695 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Navigate } from "react-router-dom";
import {
Copy,
FileText,
Loader2,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import {
ALLOWED_VARIABLES,
previewTemplate,
useNotificationTemplateActions,
useNotificationTemplates,
type NotificationTemplate,
type TemplateFormData,
} from "@/strategy-components/api/notification-templates";
const PAGE_SIZE = 15;
const VARIABLE_LABELS: Record<string, string> = {
userName: "用户名",
taskName: "任务名",
taskId: "任务ID",
dueDate: "截止日期",
};
interface ConflictInfo {
server: NotificationTemplate;
local: TemplateFormData & { id: string; expectedVersion: number };
}
export default function NotificationTemplatesPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
return <PageContent />;
}
function PageContent() {
const [keyword, setKeyword] = useState("");
const [enabledFilter, setEnabledFilter] = useState<string>("all");
const [tagFilter, setTagFilter] = useState<string>("all");
const [page, setPage] = useState(1);
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedKeyword(keyword);
setPage(1);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [keyword]);
const queryParams = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
enabled: enabledFilter === "all" ? undefined : enabledFilter === "true",
tag: tagFilter === "all" ? undefined : tagFilter,
page,
pageSize: PAGE_SIZE,
}),
[debouncedKeyword, enabledFilter, tagFilter, page],
);
const { data, isLoading, isFetching } = useNotificationTemplates(queryParams);
const actions = useNotificationTemplateActions();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const allTags = useMemo(() => {
const tags = new Set<string>();
(data?.items ?? []).forEach((t) => t.tags.forEach((tag) => tags.add(tag)));
return Array.from(tags);
}, [data?.items]);
const totalPages = Math.ceil((data?.total ?? 0) / PAGE_SIZE);
const handleCreate = () => {
setEditingTemplate(null);
setConflict(null);
setDialogOpen(true);
};
const handleEdit = (t: NotificationTemplate) => {
setEditingTemplate(t);
setConflict(null);
setDialogOpen(true);
};
const handleToggle = async (t: NotificationTemplate) => {
try {
await actions.update.mutateAsync({
id: t.id,
name: t.name,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
tags: t.tags,
enabled: !t.enabled,
expectedVersion: t.version,
});
toast.success(t.enabled ? "已停用" : "已启用");
} catch (err: unknown) {
const e = err as { status?: number; detail?: unknown };
if (e?.status === 409) {
toast.error("版本冲突,请刷新后重试");
} else {
toast.error("操作失败");
}
}
};
const handleDelete = async (id: string) => {
try {
await actions.remove.mutateAsync(id);
toast.success("已删除");
setDeleteConfirm(null);
} catch {
toast.error("删除失败");
}
};
const handleDuplicate = async (id: string) => {
try {
await actions.duplicate.mutateAsync(id);
toast.success("已复制");
} catch {
toast.error("复制失败");
}
};
return (
<div className="flex size-full min-h-0 flex-col">
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<FileText className="size-5" />
<h1 className="text-lg font-semibold">通知模板管理</h1>
</div>
<Button size="sm" onClick={handleCreate}>
<Plus className="mr-1 size-4" />
新建模板
</Button>
</div>
<div className="flex flex-wrap items-center gap-3 border-b px-6 py-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索名称/标题/正文..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="w-64 pl-8"
/>
</div>
<Select value={enabledFilter} onValueChange={(v) => { setEnabledFilter(v); setPage(1); }}>
<SelectTrigger className="w-32">
<SelectValue placeholder="启用状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="true">已启用</SelectItem>
<SelectItem value="false">已停用</SelectItem>
</SelectContent>
</Select>
<Select value={tagFilter} onValueChange={(v) => { setTagFilter(v); setPage(1); }}>
<SelectTrigger className="w-32">
<SelectValue placeholder="标签筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部标签</SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))}
</SelectContent>
</Select>
{isFetching && <Loader2 className="size-4 animate-spin text-muted-foreground" />}
</div>
<div className="flex-1 overflow-auto px-6 py-4">
{isLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !data?.items.length ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
<FileText className="size-8" />
<p>暂无模板</p>
</div>
) : (
<div className="space-y-2">
{data.items.map((t) => (
<div
key={t.id}
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{t.name}</span>
<Badge variant={t.enabled ? "default" : "secondary"}>
{t.enabled ? "启用" : "停用"}
</Badge>
<span className="text-xs text-muted-foreground">v{t.version}</span>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{t.titleTemplate}
</p>
{t.tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{t.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
<div className="ml-4 flex items-center gap-1">
<Switch checked={t.enabled} onCheckedChange={() => handleToggle(t)} />
<Button variant="ghost" size="icon" onClick={() => handleEdit(t)}>
<Pencil className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDuplicate(t.id)}>
<Copy className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(t.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t px-6 py-3">
<span className="text-sm text-muted-foreground">
共 {data?.total ?? 0} 条,第 {page}/{totalPages} 页
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
)}
{dialogOpen && (
<EditDialog
template={editingTemplate}
onClose={() => { setDialogOpen(false); setConflict(null); }}
conflict={conflict}
onConflictReload={() => {
setConflict(null);
setEditingTemplate(conflict!.server);
}}
onConflictOverride={async () => {
try {
await actions.update.mutateAsync({
id: conflict!.local.id,
name: conflict!.local.name,
titleTemplate: conflict!.local.titleTemplate,
contentTemplate: conflict!.local.contentTemplate,
tags: conflict!.local.tags,
enabled: conflict!.local.enabled,
expectedVersion: conflict!.server.version,
});
toast.success("已覆盖保存");
setDialogOpen(false);
setConflict(null);
} catch (err: unknown) {
const e = err as { status?: number; detail?: unknown };
if (e?.status === 409) {
setConflict((prev) =>
prev ? { ...prev, server: (e.detail as { server: NotificationTemplate }).server } : null,
);
toast.error("再次发生版本冲突,请重新加载");
} else {
toast.error("保存失败");
}
}
}}
/>
)}
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
删除后无法恢复,确定要删除此模板吗?
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
取消
</Button>
<Button variant="destructive" onClick={() => deleteConfirm && handleDelete(deleteConfirm)}>
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EditDialog({
template,
onClose,
conflict,
onConflictReload,
onConflictOverride,
}: {
template: NotificationTemplate | null;
onClose: () => void;
conflict: ConflictInfo | null;
onConflictReload: () => void;
onConflictOverride: () => void;
}) {
const actions = useNotificationTemplateActions();
const isEdit = !!template;
const [name, setName] = useState(template?.name ?? "");
const [titleTemplate, setTitleTemplate] = useState(template?.titleTemplate ?? "");
const [contentTemplate, setContentTemplate] = useState(template?.contentTemplate ?? "");
const [tags, setTags] = useState<string[]>(template?.tags ?? []);
const [tagInput, setTagInput] = useState("");
const [enabled, setEnabled] = useState(template?.enabled ?? true);
const [saving, setSaving] = useState(false);
const [previewVars, setPreviewVars] = useState<Record<string, string>>({
userName: "张三",
taskName: "示例任务",
taskId: "T-001",
dueDate: "2026-08-15",
});
const [previewResult, setPreviewResult] = useState<{
title: string;
content: string;
missingVariables: string[];
} | null>(null);
const [previewing, setPreviewing] = useState(false);
const handleAddTag = () => {
const tag = tagInput.trim();
if (tag && tags.length < 5 && !tags.includes(tag) && tag.length <= 20) {
setTags([...tags, tag]);
setTagInput("");
}
};
const handleRemoveTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag));
};
const handlePreview = async () => {
setPreviewing(true);
try {
const result = await previewTemplate(titleTemplate, contentTemplate, previewVars);
setPreviewResult(result);
} catch {
toast.error("预览失败");
} finally {
setPreviewing(false);
}
};
const handleSave = async () => {
if (!name.trim() || name.trim().length < 2) {
toast.error("名称长度至少 2 个字符");
return;
}
if (!titleTemplate || titleTemplate.length > 120) {
toast.error("标题长度 1~120");
return;
}
if (!contentTemplate || contentTemplate.length > 5000) {
toast.error("正文长度 1~5000");
return;
}
setSaving(true);
try {
if (isEdit) {
await actions.update.mutateAsync({
id: template!.id,
name: name.trim(),
titleTemplate,
contentTemplate,
tags,
enabled,
expectedVersion: template!.version,
});
} else {
await actions.create.mutateAsync({
name: name.trim(),
titleTemplate,
contentTemplate,
tags,
enabled,
});
}
toast.success(isEdit ? "已更新" : "已创建");
onClose();
} catch (err: unknown) {
const e = err as { status?: number; detail?: unknown };
if (e?.status === 409) {
const detail = e.detail as { message?: string; server?: NotificationTemplate } | string;
if (typeof detail === "object" && detail?.server) {
toast.error("版本冲突");
} else {
toast.error(typeof detail === "string" ? detail : "名称已存在");
}
} else if (e?.status === 422) {
const detail = e.detail as string | { detail?: string };
toast.error(typeof detail === "string" ? detail : "参数校验失败");
} else {
toast.error("保存失败");
}
} finally {
setSaving(false);
}
};
const insertVariable = (varName: string) => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-content-template="true"]',
);
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = contentTemplate.slice(0, start);
const after = contentTemplate.slice(end);
const insertion = `{{${varName}}}`;
setContentTemplate(before + insertion + after);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + insertion.length, start + insertion.length);
}, 0);
} else {
setContentTemplate((prev) => prev + `{{${varName}}}`);
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{isEdit ? "编辑模板" : "新建模板"}</DialogTitle>
</DialogHeader>
{conflict && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<p className="mb-2 text-sm font-medium text-destructive">
版本冲突:服务器版本已更新 (v{conflict.server.version})
</p>
<div className="mb-3 grid grid-cols-2 gap-4 text-xs">
<div>
<p className="mb-1 font-medium">服务器最新版本:</p>
<p className="rounded bg-background p-2">
名称: {conflict.server.name}
<br />
标题: {conflict.server.titleTemplate}
</p>
</div>
<div>
<p className="mb-1 font-medium">我的编辑内容:</p>
<p className="rounded bg-background p-2">
名称: {conflict.local.name}
<br />
标题: {conflict.local.titleTemplate}
</p>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onConflictReload}>
<RefreshCw className="mr-1 size-3" />
重新加载
</Button>
<Button size="sm" variant="destructive" onClick={onConflictOverride}>
覆盖保存
</Button>
</div>
</div>
)}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium">名称</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="2~50 字符"
maxLength={50}
/>
</div>
<div className="flex items-end gap-2">
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<span className="text-sm">{enabled ? "启用" : "停用"}</span>
</div>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">标题模板</label>
<Input
value={titleTemplate}
onChange={(e) => setTitleTemplate(e.target.value)}
placeholder="支持变量: {{userName}} {{taskName}} {{taskId}} {{dueDate}}"
maxLength={120}
/>
<p className="mt-1 text-xs text-muted-foreground">{titleTemplate.length}/120</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium">正文模板</label>
<Textarea
data-content-template="true"
value={contentTemplate}
onChange={(e) => setContentTemplate(e.target.value)}
placeholder="支持变量: {{userName}} {{taskName}} {{taskId}} {{dueDate}}"
rows={6}
maxLength={5000}
/>
<p className="mt-1 text-xs text-muted-foreground">{contentTemplate.length}/5000</p>
<div className="mt-2 flex flex-wrap gap-1">
<span className="text-xs text-muted-foreground">插入变量:</span>
{ALLOWED_VARIABLES.map((v) => (
<Button
key={v}
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => insertVariable(v)}
>
{`{{${v}}}`}
<span className="ml-1 text-muted-foreground">
{VARIABLE_LABELS[v]}
</span>
</Button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
标签 ({tags.length}/5)
</label>
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button type="button" onClick={() => handleRemoveTag(tag)}>
<X className="size-3" />
</button>
</Badge>
))}
{tags.length < 5 && (
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="输入标签后回车"
className="h-7 w-32"
maxLength={20}
/>
)}
</div>
</div>
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium">实时预览</span>
<Button
size="sm"
variant="outline"
onClick={handlePreview}
disabled={previewing}
>
{previewing ? (
<Loader2 className="mr-1 size-3 animate-spin" />
) : (
<RefreshCw className="mr-1 size-3" />
)}
预览
</Button>
</div>
<div className="mb-3 grid grid-cols-2 gap-2">
{ALLOWED_VARIABLES.map((v) => (
<div key={v}>
<label className="text-xs text-muted-foreground">
{VARIABLE_LABELS[v]} ({v})
</label>
<Input
value={previewVars[v] ?? ""}
onChange={(e) =>
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
}
className="h-7 text-xs"
/>
</div>
))}
</div>
{previewResult && (
<div className="space-y-2 rounded bg-muted p-3 text-sm">
<div>
<span className="text-xs text-muted-foreground">标题:</span>
<p className="font-medium">{previewResult.title}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">正文:</span>
<p className="whitespace-pre-wrap">{previewResult.content}</p>
</div>
{previewResult.missingVariables.length > 0 && (
<p className="text-xs text-destructive">
缺少变量: {previewResult.missingVariables.join(", ")}
</p>
)}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-1 size-4 animate-spin" />}
<Save className="mr-1 size-4" />
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+11
View File
@@ -42,6 +42,7 @@ import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage";
import LoginWhitelistPage from "./LoginWhitelistPage";
import NotificationTemplatesPage from "./NotificationTemplatesPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
@@ -438,6 +439,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/notification-templates"
element={
getStoredAuth()?.system_role === "admin" ? (
<NotificationTemplatesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/position-roles"
element={
@@ -0,0 +1,245 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
const BASE = () => `${getBackendBaseURL()}/api/notification-templates`;
export const ALLOWED_VARIABLES = ["userName", "taskName", "taskId", "dueDate"] as const;
export interface NotificationTemplate {
id: string;
name: string;
titleTemplate: string;
contentTemplate: string;
tags: string[];
enabled: boolean;
version: number;
createdBy: string | null;
updatedBy: string | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface TemplateListResult {
total: number;
items: NotificationTemplate[];
page: number;
pageSize: number;
}
export interface TemplateListParams {
keyword?: string;
enabled?: boolean;
tag?: string;
page?: number;
pageSize?: number;
}
export interface TemplateFormData {
name: string;
titleTemplate: string;
contentTemplate: string;
tags: string[];
enabled: boolean;
}
export interface TemplatePreviewResult {
title: string;
content: string;
missingVariables: string[];
}
interface ServerTemplate {
id: string;
name: string;
title_template: string;
content_template: string;
tags: string[];
enabled: boolean;
version: number;
created_by: string | null;
updated_by: string | null;
created_at: string | null;
updated_at: string | null;
}
function toTemplate(s: ServerTemplate): NotificationTemplate {
return {
id: s.id,
name: s.name,
titleTemplate: s.title_template,
contentTemplate: s.content_template,
tags: s.tags ?? [],
enabled: s.enabled,
version: s.version,
createdBy: s.created_by,
updatedBy: s.updated_by,
createdAt: s.created_at,
updatedAt: s.updated_at,
};
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
if (data && typeof data === "object" && "detail" in data) {
throw { status: res.status, detail: (data as { detail: unknown }).detail };
}
throw { status: res.status, detail: `${ctx} failed (${res.status})` };
}
return (await res.json()) as T;
}
export async function listTemplates(params: TemplateListParams = {}): Promise<TemplateListResult> {
const url = new URL(BASE());
if (params.keyword) url.searchParams.set("keyword", params.keyword);
if (params.enabled !== undefined) url.searchParams.set("enabled", String(params.enabled));
if (params.tag) url.searchParams.set("tag", params.tag);
if (params.page) url.searchParams.set("page", String(params.page));
if (params.pageSize) url.searchParams.set("page_size", String(params.pageSize));
const res = await apiFetch(url.toString(), { method: "GET" });
const data = await readJson<{
total: number;
items: ServerTemplate[];
page: number;
page_size: number;
}>(res, "list templates");
return {
total: data.total,
items: (data.items ?? []).map(toTemplate),
page: data.page,
pageSize: data.page_size,
};
}
export async function getTemplate(id: string): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${id}`, { method: "GET" });
return toTemplate(await readJson<ServerTemplate>(res, "get template"));
}
export async function createTemplate(data: TemplateFormData): Promise<NotificationTemplate> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
title_template: data.titleTemplate,
content_template: data.contentTemplate,
tags: data.tags,
enabled: data.enabled,
}),
});
const saved = toTemplate(await readJson<ServerTemplate>(res, "create template"));
writeYLog({
optType: OptType.create,
optDetail: "CM助手-通知模板-新建",
url: "/api/notification-templates",
reqParam: data,
returnParam: saved,
});
return saved;
}
export async function updateTemplate(
id: string,
data: TemplateFormData & { expectedVersion: number },
): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
title_template: data.titleTemplate,
content_template: data.contentTemplate,
tags: data.tags,
enabled: data.enabled,
expected_version: data.expectedVersion,
}),
});
const saved = toTemplate(await readJson<ServerTemplate>(res, "update template"));
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-通知模板-编辑",
url: `/api/notification-templates/${id}`,
reqParam: data,
returnParam: saved,
});
return saved;
}
export async function deleteTemplate(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
const data = await res.json().catch(() => null as unknown);
throw { status: res.status, detail: data?.detail ?? "delete failed" };
}
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-通知模板-删除",
url: `/api/notification-templates/${id}`,
reqParam: { id },
returnParam: null,
});
}
export async function duplicateTemplate(id: string): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${id}/duplicate`, { method: "POST" });
return toTemplate(await readJson<ServerTemplate>(res, "duplicate template"));
}
export async function previewTemplate(
titleTemplate: string,
contentTemplate: string,
variables: Record<string, string>,
): Promise<TemplatePreviewResult> {
const res = await apiFetch(`${BASE()}/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title_template: titleTemplate,
content_template: contentTemplate,
variables,
}),
});
const data = await readJson<{
title: string;
content: string;
missing_variables: string[];
}>(res, "preview template");
return {
title: data.title,
content: data.content,
missingVariables: data.missing_variables,
};
}
const QUERY_KEY = "notification-templates";
export function useNotificationTemplates(params: TemplateListParams = {}) {
return useQuery({
queryKey: [QUERY_KEY, params],
queryFn: () => listTemplates(params),
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
export function useNotificationTemplateActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
return {
create: useMutation({ mutationFn: createTemplate, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({ id, ...data }: TemplateFormData & { id: string; expectedVersion: number }) =>
updateTemplate(id, data),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteTemplate, onSuccess: invalidate }),
duplicate: useMutation({ mutationFn: duplicateTemplate, onSuccess: invalidate }),
};
}