From 5f11926db5ed4ca6e2fae21ed902e627fb582712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 7 Aug 2026 12:30:20 +0800 Subject: [PATCH] ceshi --- frontend-web/src/components/page-sidebar.tsx | 17 + .../src/pages/NotificationTemplatesPage.tsx | 706 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../api/notification-templates.ts | 338 +++++++++ .../backend/app/gateway/app.py | 104 +-- .../backend/app/gateway/deps.py | 90 +-- .../gateway/routers/notification_templates.py | 307 ++++++++ .../deerflow/persistence/models/__init__.py | 2 + .../notification_templates/__init__.py | 38 + .../notification_templates/base.py | 69 ++ .../notification_templates/memory.py | 135 ++++ .../notification_templates/model.py | 35 + .../persistence/notification_templates/sql.py | 163 ++++ .../notification_templates/variables.py | 110 +++ 14 files changed, 2031 insertions(+), 94 deletions(-) create mode 100644 frontend-web/src/pages/NotificationTemplatesPage.tsx create mode 100644 frontend-web/src/strategy-components/api/notification-templates.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/notification_templates.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/notification_templates/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/notification_templates/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/notification_templates/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/notification_templates/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/notification_templates/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/notification_templates/variables.py diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index eb1f0bd1..37b7ceaa 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -10,6 +10,7 @@ import { Gauge, ListTree, MousePointerClick, + FileText, BriefcaseBusiness, Settings, ShieldAlert, @@ -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,14 @@ 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..fb826a69 --- /dev/null +++ b/frontend-web/src/pages/NotificationTemplatesPage.tsx @@ -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(); + 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 = { + 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(null); + const [form, setForm] = useState(emptyForm); + const [saveError, setSaveError] = useState(null); + const [conflict, setConflict] = useState(null); + + const [deleteTarget, setDeleteTarget] = useState(null); + + const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS); + const [preview, setPreview] = useState(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 ; + } + + 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 ( +
+
+
+

+ + 通知模板管理 +

+

+ 维护消息通知的标题/正文模板。支持变量: + {NOTIFICATION_TEMPLATE_VARIABLES.map((v) => ( + + {`{{${v}}}`} + + ))} +

+
+ +
+ +
+
+ +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") applySearch(); + }} + /> + +
+
+
+ + +
+
+ + { + setTagFilter(e.target.value); + setPage(1); + }} + /> +
+
+ + {isError ? ( +
+ +

+ {error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message) + ? "无权限访问通知模板管理" + : error instanceof Error + ? error.message + : "加载失败"} +

+ +
+ ) : isLoading ? ( +
+ + 加载中… +
+ ) : items.length === 0 ? ( +
+ +

暂无通知模板

+ +
+ ) : ( +
+ + + + + + + + + + + + + {items.map((t) => ( + + + + + + + + + ))} + +
名称标题模板标签版本启用操作
{t.name} + {t.titleTemplate} + +
+ {t.tags.length === 0 ? ( + — + ) : ( + t.tags.map((tag) => ( + + {tag} + + )) + )} +
+
v{t.version} + void handleToggle(t)} /> + +
+ + + +
+
+
+ + 共 {total} 条 · 第 {page}/{totalPages} 页 + +
+ + +
+
+
+ )} + + {/* 新建 / 编辑 */} + + + + {editing ? "编辑通知模板" : "新建通知模板"} + + +
+
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, titleTemplate: e.target.value }))} + /> +
+ {NOTIFICATION_TEMPLATE_VARIABLES.map((v) => ( + + ))} +
+
+
+ +