diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index eb1f0bd1..4ca983da 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight, ChevronsUpDown, + FileText, Folder, FolderOpen, Gauge, @@ -236,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/notification-templates")} + > + + 通知模板管理 + + )} {isAdmin && ( navigate("/page/strategy/admin/position-roles")} @@ -549,6 +558,12 @@ function LegacyAccordionSidebar({ 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/notification-templates")}> + + 通知模板管理 + + )} {isAdmin && ( navigate("/page/strategy/admin/position-roles")}> diff --git a/frontend-web/src/pages/NotificationTemplatesPage.tsx b/frontend-web/src/pages/NotificationTemplatesPage.tsx new file mode 100644 index 00000000..b8f3cde8 --- /dev/null +++ b/frontend-web/src/pages/NotificationTemplatesPage.tsx @@ -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 = { + 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 ; + + return ; +} + +function PageContent() { + const [keyword, setKeyword] = useState(""); + const [enabledFilter, setEnabledFilter] = useState("all"); + const [tagFilter, setTagFilter] = useState("all"); + const [page, setPage] = useState(1); + const [debouncedKeyword, setDebouncedKeyword] = useState(""); + + const debounceRef = useRef | 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(null); + const [conflict, setConflict] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const allTags = useMemo(() => { + const tags = new Set(); + (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 ( +
+
+
+ +

通知模板管理

+
+ +
+ +
+
+ + setKeyword(e.target.value)} + className="w-64 pl-8" + /> +
+ + + {isFetching && } +
+ +
+ {isLoading ? ( +
+ +
+ ) : !data?.items.length ? ( +
+ +

暂无模板

+
+ ) : ( +
+ {data.items.map((t) => ( +
+
+
+ {t.name} + + {t.enabled ? "启用" : "停用"} + + v{t.version} +
+

+ {t.titleTemplate} +

+ {t.tags.length > 0 && ( +
+ {t.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ handleToggle(t)} /> + + + +
+
+ ))} +
+ )} +
+ + {totalPages > 1 && ( +
+ + 共 {data?.total ?? 0} 条,第 {page}/{totalPages} 页 + +
+ + +
+
+ )} + + {dialogOpen && ( + { 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("保存失败"); + } + } + }} + /> + )} + + !open && setDeleteConfirm(null)}> + + + 确认删除 + +

+ 删除后无法恢复,确定要删除此模板吗? +

+ + + + +
+
+
+ ); +} + +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(template?.tags ?? []); + const [tagInput, setTagInput] = useState(""); + const [enabled, setEnabled] = useState(template?.enabled ?? true); + const [saving, setSaving] = useState(false); + + const [previewVars, setPreviewVars] = useState>({ + 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( + '[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 ( + !open && onClose()}> + + + {isEdit ? "编辑模板" : "新建模板"} + + + {conflict && ( +
+

+ 版本冲突:服务器版本已更新 (v{conflict.server.version}) +

+
+
+

服务器最新版本:

+

+ 名称: {conflict.server.name} +
+ 标题: {conflict.server.titleTemplate} +

+
+
+

我的编辑内容:

+

+ 名称: {conflict.local.name} +
+ 标题: {conflict.local.titleTemplate} +

+
+
+
+ + +
+
+ )} + +
+
+
+ + setName(e.target.value)} + placeholder="2~50 字符" + maxLength={50} + /> +
+
+
+ + {enabled ? "启用" : "停用"} +
+
+
+ +
+ + setTitleTemplate(e.target.value)} + placeholder="支持变量: {{userName}} {{taskName}} {{taskId}} {{dueDate}}" + maxLength={120} + /> +

{titleTemplate.length}/120

+
+ +
+ +