ceshi
This commit is contained in:
1 parent
1ffead7ee2
commit
5f11926db5
14 files changed
+2031
-94
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
Gauge,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
FileText,
|
||||
BriefcaseBusiness,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
@@ -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,14 @@ 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,706 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知模板管理(仅 admin):创建/维护消息通知模板,支持变量校验与实时预览、
|
||||
* 乐观锁冲突对比(重新加载 / 覆盖保存)。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
FileText,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 { cn } from "@/lib/utils";
|
||||
import {
|
||||
NOTIFICATION_TEMPLATE_VARIABLES,
|
||||
NotificationTemplateConflictError,
|
||||
previewNotificationTemplate,
|
||||
useNotificationTemplateActions,
|
||||
useNotificationTemplates,
|
||||
type NotificationTemplate,
|
||||
type NotificationTemplateInput,
|
||||
type PreviewResult,
|
||||
} from "@/strategy-components/api/notification-templates";
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
tagsText: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ConflictState {
|
||||
server: NotificationTemplate;
|
||||
mine: FormState;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
titleTemplate: "",
|
||||
contentTemplate: "",
|
||||
tagsText: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
function formFromTemplate(t: NotificationTemplate): FormState {
|
||||
return {
|
||||
name: t.name,
|
||||
titleTemplate: t.titleTemplate,
|
||||
contentTemplate: t.contentTemplate,
|
||||
tagsText: (t.tags ?? []).join(", "),
|
||||
enabled: t.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of text.split(/[,,]/)) {
|
||||
const tag = part.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
out.push(tag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toInput(form: FormState): NotificationTemplateInput {
|
||||
return {
|
||||
name: form.name,
|
||||
titleTemplate: form.titleTemplate,
|
||||
contentTemplate: form.contentTemplate,
|
||||
tags: parseTags(form.tagsText),
|
||||
enabled: form.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_VARS: Record<string, string> = {
|
||||
userName: "张三",
|
||||
taskName: "示例任务",
|
||||
taskId: "T-001",
|
||||
dueDate: "2026-08-10",
|
||||
};
|
||||
|
||||
export default function NotificationTemplatesPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const actions = useNotificationTemplateActions();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [keywordApplied, setKeywordApplied] = useState("");
|
||||
const [enabledFilter, setEnabledFilter] = useState<"all" | "true" | "false">("all");
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
keyword: keywordApplied || undefined,
|
||||
enabled:
|
||||
enabledFilter === "all" ? null : enabledFilter === "true" ? true : false,
|
||||
tag: tagFilter.trim() || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
[keywordApplied, enabledFilter, tagFilter, page, pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useNotificationTemplates(listParams);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<NotificationTemplate | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<ConflictState | null>(null);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<NotificationTemplate | null>(null);
|
||||
|
||||
const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS);
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
const saving =
|
||||
actions.create.isPending || actions.update.isPending || actions.duplicate.isPending;
|
||||
|
||||
// 实时预览(编辑弹窗打开时)
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
setPreviewLoading(true);
|
||||
void previewNotificationTemplate({
|
||||
titleTemplate: form.titleTemplate,
|
||||
contentTemplate: form.contentTemplate,
|
||||
variables: previewVars,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setPreview(r);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPreview(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
}, 280);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [dialogOpen, form.titleTemplate, form.contentTemplate, previewVars]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setSaveError(null);
|
||||
setConflict(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (t: NotificationTemplate) => {
|
||||
setEditing(t);
|
||||
setForm(formFromTemplate(t));
|
||||
setSaveError(null);
|
||||
setConflict(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const applySearch = () => {
|
||||
setPage(1);
|
||||
setKeywordApplied(keyword.trim());
|
||||
};
|
||||
|
||||
const handleSave = async (opts?: { forceVersion?: number }) => {
|
||||
setSaveError(null);
|
||||
const input = toInput(form);
|
||||
try {
|
||||
if (editing) {
|
||||
const version = opts?.forceVersion ?? editing.version;
|
||||
const updated = await actions.update.mutateAsync({
|
||||
id: editing.id,
|
||||
input: { ...input, expectedVersion: version },
|
||||
});
|
||||
setEditing(updated);
|
||||
setConflict(null);
|
||||
toast.success("模板已保存");
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
await actions.create.mutateAsync(input);
|
||||
toast.success("模板已创建");
|
||||
setDialogOpen(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof NotificationTemplateConflictError) {
|
||||
setConflict({ server: e.server, mine: form });
|
||||
setSaveError("版本冲突:服务器上的模板已被他人更新,请选择重新加载或覆盖保存。");
|
||||
return;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
setSaveError(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReloadFromServer = () => {
|
||||
if (!conflict) return;
|
||||
setEditing(conflict.server);
|
||||
setForm(formFromTemplate(conflict.server));
|
||||
setConflict(null);
|
||||
setSaveError(null);
|
||||
toast.info("已加载服务器最新版本");
|
||||
};
|
||||
|
||||
const handleForceOverwrite = async () => {
|
||||
if (!conflict) return;
|
||||
await handleSave({ forceVersion: conflict.server.version });
|
||||
};
|
||||
|
||||
const handleToggle = async (t: NotificationTemplate) => {
|
||||
try {
|
||||
await actions.update.mutateAsync({
|
||||
id: t.id,
|
||||
input: {
|
||||
name: t.name,
|
||||
titleTemplate: t.titleTemplate,
|
||||
contentTemplate: t.contentTemplate,
|
||||
tags: t.tags,
|
||||
enabled: !t.enabled,
|
||||
expectedVersion: t.version,
|
||||
},
|
||||
});
|
||||
toast.success(t.enabled ? "已停用" : "已启用");
|
||||
} catch (e) {
|
||||
if (e instanceof NotificationTemplateConflictError) {
|
||||
toast.error("版本冲突,请刷新列表后重试");
|
||||
void refetch();
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof Error ? e.message : "启停失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (t: NotificationTemplate) => {
|
||||
try {
|
||||
await actions.duplicate.mutateAsync(t.id);
|
||||
toast.success("已复制");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(deleteTarget.id);
|
||||
toast.success("已删除");
|
||||
setDeleteTarget(null);
|
||||
if (items.length <= 1 && page > 1) setPage((p) => p - 1);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const insertVariable = (name: string, field: "titleTemplate" | "contentTemplate") => {
|
||||
const token = `{{${name}}}`;
|
||||
setForm((f) => ({ ...f, [field]: `${f[field]}${token}` }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 p-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
|
||||
<FileText className="size-5" />
|
||||
通知模板管理
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
维护消息通知的标题/正文模板。支持变量:
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<code key={v} className="mx-1 rounded bg-muted px-1.5 py-0.5 text-xs">
|
||||
{`{{${v}}}`}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
新建模板
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">关键字</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder="搜索名称 / 标题 / 正文"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" onClick={applySearch}>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">启用状态</label>
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
onValueChange={(v) => {
|
||||
setEnabledFilter(v as "all" | "true" | "false");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="true">已启用</SelectItem>
|
||||
<SelectItem value="false">已停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">标签</label>
|
||||
<Input
|
||||
value={tagFilter}
|
||||
placeholder="精确匹配标签"
|
||||
onChange={(e) => {
|
||||
setTagFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-12 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message)
|
||||
? "无权限访问通知模板管理"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "加载失败"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
|
||||
<FileText className="size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">暂无通知模板</p>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
创建第一个模板
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("overflow-hidden rounded-lg border", isFetching && "opacity-70")}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">名称</th>
|
||||
<th className="px-3 py-2 font-medium">标题模板</th>
|
||||
<th className="px-3 py-2 font-medium">标签</th>
|
||||
<th className="px-3 py-2 font-medium">版本</th>
|
||||
<th className="px-3 py-2 font-medium">启用</th>
|
||||
<th className="px-3 py-2 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((t) => (
|
||||
<tr key={t.id} className="border-t">
|
||||
<td className="px-3 py-2.5 font-medium">{t.name}</td>
|
||||
<td className="max-w-[240px] truncate px-3 py-2.5 text-muted-foreground">
|
||||
{t.titleTemplate}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.tags.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
t.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">v{t.version}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Switch checked={t.enabled} onCheckedChange={() => void handleToggle(t)} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEdit(t)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleDuplicate(t)}
|
||||
title="复制"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteTarget(t)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建 / 编辑 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑通知模板" : "新建通知模板"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">名称</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
maxLength={50}
|
||||
placeholder="2~50 个字符"
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">标题模板</label>
|
||||
<Input
|
||||
value={form.titleTemplate}
|
||||
maxLength={120}
|
||||
placeholder="如:任务 {{taskName}} 即将到期"
|
||||
onChange={(e) => setForm((f) => ({ ...f, titleTemplate: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => insertVariable(v, "titleTemplate")}
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">正文模板</label>
|
||||
<Textarea
|
||||
value={form.contentTemplate}
|
||||
rows={7}
|
||||
maxLength={5000}
|
||||
placeholder="支持 {{userName}} / {{taskName}} / {{taskId}} / {{dueDate}}"
|
||||
onChange={(e) => setForm((f) => ({ ...f, contentTemplate: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => insertVariable(v, "contentTemplate")}
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
标签(逗号分隔,最多 5 个)
|
||||
</label>
|
||||
<Input
|
||||
value={form.tagsText}
|
||||
placeholder="提醒, 任务"
|
||||
onChange={(e) => setForm((f) => ({ ...f, tagsText: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
/>
|
||||
<span className="text-sm">启用</span>
|
||||
{editing && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
当前编辑版本 v{editing.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
|
||||
<div className="text-sm font-medium">实时预览</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<div key={v}>
|
||||
<label className="mb-0.5 block text-[11px] text-muted-foreground">{v}</label>
|
||||
<Input
|
||||
value={previewVars[v] ?? ""}
|
||||
onChange={(e) =>
|
||||
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded border bg-background p-3 text-sm">
|
||||
{previewLoading && !preview ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
预览中…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 font-medium">{preview?.title || "(标题预览)"}</div>
|
||||
<div className="whitespace-pre-wrap text-muted-foreground">
|
||||
{preview?.content || "(正文预览)"}
|
||||
</div>
|
||||
{preview && preview.missingVariables.length > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
缺少变量:{preview.missingVariables.join(", ")}(保留原占位符)
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="size-4" />
|
||||
版本冲突 — 请选择如何处理
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded border bg-background p-2 text-xs">
|
||||
<div className="mb-1 font-medium">服务器最新版本 (v{conflict.server.version})</div>
|
||||
<div className="text-muted-foreground">名称:{conflict.server.name}</div>
|
||||
<div className="text-muted-foreground">标题:{conflict.server.titleTemplate}</div>
|
||||
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||
{conflict.server.contentTemplate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border bg-background p-2 text-xs">
|
||||
<div className="mb-1 font-medium">我的编辑内容</div>
|
||||
<div className="text-muted-foreground">名称:{conflict.mine.name}</div>
|
||||
<div className="text-muted-foreground">标题:{conflict.mine.titleTemplate}</div>
|
||||
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||
{conflict.mine.contentTemplate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleReloadFromServer}>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={saving} onClick={() => void handleForceOverwrite()}>
|
||||
覆盖保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={saving || !!conflict} onClick={() => void handleSave()}>
|
||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定删除模板「{deleteTarget?.name}」?此操作不可恢复。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={actions.remove.isPending}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
{actions.remove.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||
import PositionRolesPage from "./PositionRolesPage";
|
||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
@@ -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,338 @@
|
||||
/**
|
||||
* 通知模板管理 API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/notification-templates`(见 app/gateway/routers/notification_templates.py)。
|
||||
* 全局共享;读/写均仅 admin。snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import {
|
||||
keepPreviousData,
|
||||
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";
|
||||
|
||||
export const NOTIFICATION_TEMPLATE_VARIABLES = [
|
||||
"userName",
|
||||
"taskName",
|
||||
"taskId",
|
||||
"dueDate",
|
||||
] as const;
|
||||
|
||||
export type NotificationTemplateVariable =
|
||||
(typeof NOTIFICATION_TEMPLATE_VARIABLES)[number];
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationTemplateInput {
|
||||
name: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationTemplateUpdateInput extends NotificationTemplateInput {
|
||||
expectedVersion: number;
|
||||
}
|
||||
|
||||
export interface ListNotificationTemplatesParams {
|
||||
keyword?: string;
|
||||
enabled?: boolean | null;
|
||||
tag?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface NotificationTemplateListResult {
|
||||
items: NotificationTemplate[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
title: string;
|
||||
content: string;
|
||||
missingVariables: string[];
|
||||
}
|
||||
|
||||
/** 409 乐观锁冲突:携带服务端最新完整记录。 */
|
||||
export class NotificationTemplateConflictError extends Error {
|
||||
readonly server: NotificationTemplate;
|
||||
|
||||
constructor(server: NotificationTemplate, message = "版本冲突") {
|
||||
super(message);
|
||||
this.name = "NotificationTemplateConflictError";
|
||||
this.server = server;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/notification-templates`;
|
||||
|
||||
interface ServerTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
title_template: string;
|
||||
content_template: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
created_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 ?? true,
|
||||
version: s.version ?? 1,
|
||||
createdBy: s.created_by ?? null,
|
||||
createdAt: s.created_at ?? null,
|
||||
updatedAt: s.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toBody(input: NotificationTemplateInput & { expectedVersion?: number }) {
|
||||
const body: Record<string, unknown> = {
|
||||
name: input.name,
|
||||
title_template: input.titleTemplate,
|
||||
content_template: input.contentTemplate,
|
||||
tags: input.tags,
|
||||
enabled: input.enabled,
|
||||
};
|
||||
if (input.expectedVersion !== undefined) {
|
||||
body.expected_version = input.expectedVersion;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function detailMessage(data: unknown, fallback: string): string {
|
||||
if (!data || typeof data !== "object" || !("detail" in data)) return fallback;
|
||||
const detail = (data as { detail: unknown }).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (detail && typeof detail === "object" && "message" in detail) {
|
||||
const msg = (detail as { message: unknown }).message;
|
||||
if (typeof msg === "string") return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (res.status === 204) return undefined as T;
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
if (!res.ok) {
|
||||
if (res.status === 409 && data && typeof data === "object" && "detail" in data) {
|
||||
const detail = (data as { detail: unknown }).detail;
|
||||
if (detail && typeof detail === "object" && "server" in detail) {
|
||||
const server = (detail as { server: ServerTemplate }).server;
|
||||
if (server && typeof server === "object" && "id" in server) {
|
||||
throw new NotificationTemplateConflictError(
|
||||
toTemplate(server),
|
||||
detailMessage(data, "版本冲突"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(detailMessage(data, `${ctx} failed (${res.status})`));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function listNotificationTemplates(
|
||||
params: ListNotificationTemplatesParams = {},
|
||||
): Promise<NotificationTemplateListResult> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.keyword) qs.set("keyword", params.keyword);
|
||||
if (params.enabled === true || params.enabled === false) {
|
||||
qs.set("enabled", String(params.enabled));
|
||||
}
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.pageSize) qs.set("page_size", String(params.pageSize));
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
|
||||
const data = await readJson<{
|
||||
items: ServerTemplate[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>(res, "list notification templates");
|
||||
const result: NotificationTemplateListResult = {
|
||||
items: (data.items ?? []).map(toTemplate),
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? 1,
|
||||
pageSize: data.page_size ?? params.pageSize ?? 20,
|
||||
};
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "CM助手-全量-通知模板-列表",
|
||||
url: `${BASE()}${query ? `?${query}` : ""}`,
|
||||
reqParam: params,
|
||||
returnParam: { total: result.total, page: result.page },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getNotificationTemplate(id: string): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "get notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "CM助手-全量-通知模板-查看",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||
reqParam: { id },
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function createNotificationTemplate(
|
||||
input: NotificationTemplateInput,
|
||||
): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(input)),
|
||||
});
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "create notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "CM助手-全量-通知模板-创建",
|
||||
url: BASE(),
|
||||
reqParam: input,
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function updateNotificationTemplate(
|
||||
id: string,
|
||||
input: NotificationTemplateUpdateInput,
|
||||
): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(input)),
|
||||
});
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "update notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-通知模板-修改",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||
reqParam: { id, input },
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function deleteNotificationTemplate(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await readJson<void>(res, "delete notification template");
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "CM助手-全量-通知模板-删除",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function duplicateNotificationTemplate(
|
||||
id: string,
|
||||
): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/duplicate`, {
|
||||
method: "POST",
|
||||
});
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "duplicate notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "CM助手-全量-通知模板-复制",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}/duplicate`,
|
||||
reqParam: { id },
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function previewNotificationTemplate(input: {
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
variables: Record<string, string>;
|
||||
}): Promise<PreviewResult> {
|
||||
const res = await apiFetch(`${BASE()}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title_template: input.titleTemplate,
|
||||
content_template: input.contentTemplate,
|
||||
variables: input.variables,
|
||||
}),
|
||||
});
|
||||
const data = await readJson<{
|
||||
title: string;
|
||||
content: string;
|
||||
missing_variables: string[];
|
||||
}>(res, "preview notification template");
|
||||
return {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
missingVariables: data.missing_variables ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const KEY = "notification-templates";
|
||||
|
||||
export function useNotificationTemplates(params: ListNotificationTemplatesParams) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
KEY,
|
||||
"list",
|
||||
params.keyword ?? "",
|
||||
params.enabled === true ? "1" : params.enabled === false ? "0" : "",
|
||||
params.tag ?? "",
|
||||
params.page ?? 1,
|
||||
params.pageSize ?? 20,
|
||||
],
|
||||
queryFn: () => listNotificationTemplates(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationTemplateActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: [KEY] });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createNotificationTemplate, onSuccess: invalidate }),
|
||||
update: useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: NotificationTemplateUpdateInput }) =>
|
||||
updateNotificationTemplate(id, input),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({ mutationFn: deleteNotificationTemplate, onSuccess: invalidate }),
|
||||
duplicate: useMutation({
|
||||
mutationFn: duplicateNotificationTemplate,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user