日志比较
This commit is contained in:
1 parent
aa3742b103
commit
c555b92089
13 files changed
+1702
-1
No files matched your search
@@ -5,6 +5,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -236,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
通知模板管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||||
@@ -549,6 +558,12 @@ function LegacyAccordionSidebar({
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/notification-templates")}>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
通知模板管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||||
<BriefcaseBusiness className="size-4" />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import LightAppListPage from "./LightAppListPage";
|
|||||||
import LightAppManagementPage from "./LightAppManagementPage";
|
import LightAppManagementPage from "./LightAppManagementPage";
|
||||||
import MenuManagementPage from "./MenuManagementPage";
|
import MenuManagementPage from "./MenuManagementPage";
|
||||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||||
|
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||||
import SkillAddressesPage from "./SkillAddressesPage";
|
import SkillAddressesPage from "./SkillAddressesPage";
|
||||||
import TaskButtonsPage from "./TaskButtonsPage";
|
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
|
<Route
|
||||||
path="admin/position-roles"
|
path="admin/position-roles"
|
||||||
element={
|
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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,7 +57,8 @@ from app.gateway.routers import (
|
|||||||
menu_overrides,
|
menu_overrides,
|
||||||
models,
|
models,
|
||||||
multi_agent,
|
multi_agent,
|
||||||
notifications,
|
notification_templates,
|
||||||
|
notifications,
|
||||||
open_3qfx,
|
open_3qfx,
|
||||||
open_chat,
|
open_chat,
|
||||||
parallel_agent_tasks,
|
parallel_agent_tasks,
|
||||||
@@ -1609,6 +1610,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||||||
app.include_router(task_buttons.router)
|
app.include_router(task_buttons.router)
|
||||||
|
|
||||||
|
# Notification template management (通知模板管理) at /api/notification-templates
|
||||||
|
app.include_router(notification_templates.router)
|
||||||
|
|
||||||
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
|
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
|
||||||
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
||||||
app.include_router(maf_roundtable.router)
|
app.include_router(maf_roundtable.router)
|
||||||
|
|||||||
@@ -358,6 +358,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.task_button_store = make_task_button_store(sf)
|
app.state.task_button_store = make_task_button_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates import make_notification_template_store
|
||||||
|
|
||||||
|
app.state.notification_template_store = make_notification_template_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||||
|
|
||||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""Notification template management API (通知模板管理).
|
||||||
|
|
||||||
|
Admin-only CRUD for message notification templates with variable validation,
|
||||||
|
optimistic locking, and live preview.
|
||||||
|
|
||||||
|
Routes (prefix ``/api/notification-templates``):
|
||||||
|
GET "" list templates (admin, paginated)
|
||||||
|
GET "/{id}" get single template (admin)
|
||||||
|
POST "" create template (admin)
|
||||||
|
PUT "/{id}" update template (admin, optimistic lock)
|
||||||
|
DELETE "/{id}" delete template (admin)
|
||||||
|
POST "/{id}/duplicate" duplicate template (admin)
|
||||||
|
POST "/preview" preview template with variables (admin)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||||||
|
from deerflow.persistence.notification_templates import NotificationTemplateStore
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
|
||||||
|
|
||||||
|
ALLOWED_VARIABLES = {"userName", "taskName", "taskId", "dueDate"}
|
||||||
|
|
||||||
|
_VAR_PATTERN = re.compile(r"\{\{([^}]*)\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_template_variables(text: str, field_name: str) -> None:
|
||||||
|
for m in _VAR_PATTERN.finditer(text):
|
||||||
|
inner = m.group(1)
|
||||||
|
if not inner or inner != inner.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"{field_name}: 变量格式错误 '{m.group(0)}',变量名不能为空或包含空格",
|
||||||
|
)
|
||||||
|
var_name = inner.strip()
|
||||||
|
if var_name not in ALLOWED_VARIABLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"{field_name}: 不支持的变量 '{{{{{var_name}}}}}',"
|
||||||
|
f"仅支持: {', '.join(sorted(ALLOWED_VARIABLES))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tags(tags: list[str]) -> list[str]:
|
||||||
|
cleaned: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for t in tags:
|
||||||
|
t = t.strip()
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if len(t) > 20:
|
||||||
|
raise HTTPException(status_code=422, detail=f"标签长度超过 20: '{t}'")
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t)
|
||||||
|
cleaned.append(t)
|
||||||
|
if len(cleaned) > 5:
|
||||||
|
raise HTTPException(status_code=422, detail="标签数量不能超过 5 个")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# ── schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=50)
|
||||||
|
title_template: str = Field(..., min_length=1, max_length=120)
|
||||||
|
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateUpdateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=50)
|
||||||
|
title_template: str = Field(..., min_length=1, max_length=120)
|
||||||
|
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
enabled: bool = True
|
||||||
|
expected_version: int = Field(..., ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
title_template: str
|
||||||
|
content_template: str
|
||||||
|
tags: list[str]
|
||||||
|
enabled: bool
|
||||||
|
version: int
|
||||||
|
created_by: str | None = None
|
||||||
|
updated_by: str | None = None
|
||||||
|
created_at: datetime | str | None = None
|
||||||
|
updated_at: datetime | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[TemplateResponse]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRequest(BaseModel):
|
||||||
|
title_template: str = Field(default="", max_length=120)
|
||||||
|
content_template: str = Field(default="", max_length=5000)
|
||||||
|
variables: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewResponse(BaseModel):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
missing_variables: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_store(request: Request) -> NotificationTemplateStore:
|
||||||
|
store = getattr(request.app.state, "notification_template_store", None)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Notification template store not available")
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_admin(request: Request) -> str | None:
|
||||||
|
user = await get_optional_user_from_request(request)
|
||||||
|
if user is not None and getattr(user, "system_role", None) != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="通知模板管理仅限管理员")
|
||||||
|
return await get_current_user(request)
|
||||||
|
|
||||||
|
|
||||||
|
# ── routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TemplateListResponse)
|
||||||
|
async def list_templates(
|
||||||
|
request: Request,
|
||||||
|
keyword: str | None = Query(default=None),
|
||||||
|
enabled: bool | None = Query(default=None),
|
||||||
|
tag: str | None = Query(default=None),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
) -> TemplateListResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
result = await store.list_templates(
|
||||||
|
keyword=keyword, enabled=enabled, tag=tag, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
return TemplateListResponse(
|
||||||
|
total=result["total"],
|
||||||
|
items=[TemplateResponse(**r) for r in result["items"]],
|
||||||
|
page=result["page"],
|
||||||
|
page_size=result["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}", response_model=TemplateResponse)
|
||||||
|
async def get_template(request: Request, template_id: str) -> TemplateResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
row = await store.get_template(template_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TemplateResponse, status_code=201)
|
||||||
|
async def create_template(request: Request, body: TemplateCreateRequest) -> TemplateResponse:
|
||||||
|
user_id = await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
|
||||||
|
name = body.name.strip()
|
||||||
|
if len(name) < 2:
|
||||||
|
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
|
||||||
|
|
||||||
|
if await store.name_exists(name):
|
||||||
|
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
|
||||||
|
|
||||||
|
_validate_template_variables(body.title_template, "title_template")
|
||||||
|
_validate_template_variables(body.content_template, "content_template")
|
||||||
|
tags = _validate_tags(body.tags)
|
||||||
|
|
||||||
|
row = await store.create_template(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"title_template": body.title_template,
|
||||||
|
"content_template": body.content_template,
|
||||||
|
"tags": tags,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}", response_model=TemplateResponse)
|
||||||
|
async def update_template(request: Request, template_id: str, body: TemplateUpdateRequest) -> TemplateResponse:
|
||||||
|
user_id = await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
|
||||||
|
name = body.name.strip()
|
||||||
|
if len(name) < 2:
|
||||||
|
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
|
||||||
|
|
||||||
|
if await store.name_exists(name, exclude_id=template_id):
|
||||||
|
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
|
||||||
|
|
||||||
|
_validate_template_variables(body.title_template, "title_template")
|
||||||
|
_validate_template_variables(body.content_template, "content_template")
|
||||||
|
tags = _validate_tags(body.tags)
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = await store.update_template(
|
||||||
|
template_id,
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"title_template": body.title_template,
|
||||||
|
"content_template": body.content_template,
|
||||||
|
"tags": tags,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
expected_version=body.expected_version,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
if msg == "NOT_FOUND":
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
if msg == "VERSION_CONFLICT":
|
||||||
|
latest = await store.get_template(template_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "版本冲突,服务器版本已更新",
|
||||||
|
"server": TemplateResponse(**latest).model_dump() if latest else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}", status_code=204)
|
||||||
|
async def delete_template(request: Request, template_id: str) -> None:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
deleted = await store.delete_template(template_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{template_id}/duplicate", response_model=TemplateResponse, status_code=201)
|
||||||
|
async def duplicate_template(request: Request, template_id: str) -> TemplateResponse:
|
||||||
|
user_id = await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
try:
|
||||||
|
row = await store.duplicate_template(template_id, created_by=user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "NOT_FOUND":
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
raise
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview", response_model=PreviewResponse)
|
||||||
|
async def preview_template(request: Request, body: PreviewRequest) -> PreviewResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
_validate_template_variables(body.title_template, "title_template")
|
||||||
|
_validate_template_variables(body.content_template, "content_template")
|
||||||
|
|
||||||
|
all_vars = set()
|
||||||
|
for text in (body.title_template, body.content_template):
|
||||||
|
for m in _VAR_PATTERN.finditer(text):
|
||||||
|
all_vars.add(m.group(1).strip())
|
||||||
|
|
||||||
|
missing = sorted(all_vars - set(body.variables.keys()))
|
||||||
|
|
||||||
|
def _replace(text: str) -> str:
|
||||||
|
def _sub(m: re.Match) -> str:
|
||||||
|
var_name = m.group(1).strip()
|
||||||
|
return body.variables.get(var_name, m.group(0))
|
||||||
|
return _VAR_PATTERN.sub(_sub, text)
|
||||||
|
|
||||||
|
return PreviewResponse(
|
||||||
|
title=_replace(body.title_template),
|
||||||
|
content=_replace(body.content_template),
|
||||||
|
missing_variables=missing,
|
||||||
|
)
|
||||||
+2
@@ -53,6 +53,7 @@ from deerflow.persistence.maf_roundtable.model import (
|
|||||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
from deerflow.persistence.notifications.model import NotificationRow
|
from deerflow.persistence.notifications.model import NotificationRow
|
||||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||||
from deerflow.persistence.position_roles.model import PositionRoleRow
|
from deerflow.persistence.position_roles.model import PositionRoleRow
|
||||||
@@ -124,6 +125,7 @@ __all__ = [
|
|||||||
"MafRoundtableSessionRow",
|
"MafRoundtableSessionRow",
|
||||||
"MenuOverrideRow",
|
"MenuOverrideRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
|
"NotificationTemplateRow",
|
||||||
"ParallelAgentTaskRow",
|
"ParallelAgentTaskRow",
|
||||||
"PositionRoundtableNodeRow",
|
"PositionRoundtableNodeRow",
|
||||||
"PositionRoundtableSessionRow",
|
"PositionRoundtableSessionRow",
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
"""Notification template persistence (通知模板管理)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||||
|
from deerflow.persistence.notification_templates.memory import MemoryNotificationTemplateStore
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
|
from deerflow.persistence.notification_templates.sql import NotificationTemplateRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MemoryNotificationTemplateStore",
|
||||||
|
"NotificationTemplateRepository",
|
||||||
|
"NotificationTemplateRow",
|
||||||
|
"NotificationTemplateStore",
|
||||||
|
"make_notification_template_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_notification_template_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemoryNotificationTemplateStore()
|
||||||
|
return NotificationTemplateRepository(session_factory)
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
"""Abstract notification template persistence API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_template(
|
||||||
|
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
"""In-memory notification template store (used when no DB is configured / tests)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(data: dict[str, Any], *, created_by: str | None = None, updated_by: str | None = None) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
return {
|
||||||
|
"id": str(data.get("id") or uuid.uuid4().hex),
|
||||||
|
"name": str(data["name"]),
|
||||||
|
"title_template": str(data.get("title_template") or ""),
|
||||||
|
"content_template": str(data.get("content_template") or ""),
|
||||||
|
"tags": list(data.get("tags") or []),
|
||||||
|
"enabled": bool(data.get("enabled", True)),
|
||||||
|
"version": int(data.get("version", 1)),
|
||||||
|
"created_by": data.get("created_by") or created_by,
|
||||||
|
"updated_by": updated_by or data.get("updated_by") or created_by,
|
||||||
|
"created_at": data.get("created_at") or now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryNotificationTemplateStore(NotificationTemplateStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._rows: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
items = list(self._rows.values())
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
kw = keyword.lower()
|
||||||
|
items = [
|
||||||
|
r for r in items
|
||||||
|
if kw in r["name"].lower() or kw in r["title_template"].lower() or kw in r["content_template"].lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
items = [r for r in items if r["enabled"] == enabled]
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
items = [r for r in items if tag in r.get("tags", [])]
|
||||||
|
|
||||||
|
items.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||||
|
total = len(items)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
page_items = items[offset : offset + page_size]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [dict(r) for r in page_items],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
row = _normalize(data, created_by=created_by)
|
||||||
|
self._rows[row["id"]] = row
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def update_template(
|
||||||
|
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
if row["version"] != expected_version:
|
||||||
|
raise ValueError("VERSION_CONFLICT")
|
||||||
|
for key in ("name", "title_template", "content_template", "tags", "enabled"):
|
||||||
|
if key in data:
|
||||||
|
row[key] = data[key]
|
||||||
|
row["version"] = row["version"] + 1
|
||||||
|
row["updated_by"] = updated_by
|
||||||
|
row["updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
if template_id in self._rows:
|
||||||
|
del self._rows[template_id]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
base_name = f"{row['name']} - 副本"
|
||||||
|
new_name = base_name
|
||||||
|
counter = 1
|
||||||
|
existing_names = {r["name"] for r in self._rows.values()}
|
||||||
|
while new_name in existing_names:
|
||||||
|
counter += 1
|
||||||
|
new_name = f"{base_name} {counter}"
|
||||||
|
|
||||||
|
new_row = _normalize(
|
||||||
|
{
|
||||||
|
"name": new_name,
|
||||||
|
"title_template": row["title_template"],
|
||||||
|
"content_template": row["content_template"],
|
||||||
|
"tags": list(row.get("tags", [])),
|
||||||
|
"enabled": row["enabled"],
|
||||||
|
},
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
self._rows[new_row["id"]] = new_row
|
||||||
|
return dict(new_row)
|
||||||
|
|
||||||
|
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||||
|
for rid, r in self._rows.items():
|
||||||
|
if r["name"] == name and (exclude_id is None or rid != exclude_id):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
"""ORM model for notification templates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateRow(Base):
|
||||||
|
__tablename__ = "notification_templates"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
title_template: Mapped[str] = mapped_column(String(240), default="")
|
||||||
|
content_template: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
tags: Mapped[list] = mapped_column(PortableJSON(), default=list)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
"""SQLAlchemy-backed notification template store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dict(row: NotificationTemplateRow) -> dict[str, Any]:
|
||||||
|
data = row.to_dict()
|
||||||
|
for key in ("created_at", "updated_at"):
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
data[key] = value.isoformat()
|
||||||
|
data["tags"] = data.get("tags") or []
|
||||||
|
data["enabled"] = bool(data.get("enabled", True))
|
||||||
|
data["version"] = int(data.get("version", 1))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateRepository(NotificationTemplateStore):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
stmt = select(NotificationTemplateRow)
|
||||||
|
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
kw = f"%{keyword}%"
|
||||||
|
filt = or_(
|
||||||
|
NotificationTemplateRow.name.ilike(kw),
|
||||||
|
NotificationTemplateRow.title_template.ilike(kw),
|
||||||
|
NotificationTemplateRow.content_template.ilike(kw),
|
||||||
|
)
|
||||||
|
stmt = stmt.where(filt)
|
||||||
|
count_stmt = count_stmt.where(filt)
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||||
|
count_stmt = count_stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
all_rows_stmt = stmt
|
||||||
|
all_rows_count_stmt = count_stmt
|
||||||
|
# Filter by tag in JSON array — load all and filter in Python for portability
|
||||||
|
stmt = select(NotificationTemplateRow)
|
||||||
|
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
|
||||||
|
if keyword:
|
||||||
|
kw = f"%{keyword}%"
|
||||||
|
filt = or_(
|
||||||
|
NotificationTemplateRow.name.ilike(kw),
|
||||||
|
NotificationTemplateRow.title_template.ilike(kw),
|
||||||
|
NotificationTemplateRow.content_template.ilike(kw),
|
||||||
|
)
|
||||||
|
stmt = stmt.where(filt)
|
||||||
|
count_stmt = count_stmt.where(filt)
|
||||||
|
if enabled is not None:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||||
|
count_stmt = count_stmt.where(enabled == NotificationTemplateRow.enabled)
|
||||||
|
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt.order_by(NotificationTemplateRow.created_at.desc()))
|
||||||
|
all_rows = list(result.scalars())
|
||||||
|
filtered = [r for r in all_rows if tag in (r.tags or [])]
|
||||||
|
total = len(filtered)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
page_rows = filtered[offset : offset + page_size]
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [_to_dict(r) for r in page_rows],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with self._sf() as session:
|
||||||
|
total_result = await session.execute(count_stmt)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
stmt = stmt.order_by(NotificationTemplateRow.created_at.desc()).offset(offset).limit(page_size)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
rows = list(result.scalars())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [_to_dict(r) for r in rows],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
return _to_dict(row) if row else None
|
||||||
|
|
||||||
|
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
row = NotificationTemplateRow(
|
||||||
|
id=str(data.get("id") or uuid.uuid4().hex),
|
||||||
|
name=str(data["name"]),
|
||||||
|
title_template=str(data.get("title_template") or ""),
|
||||||
|
content_template=str(data.get("content_template") or ""),
|
||||||
|
tags=list(data.get("tags") or []),
|
||||||
|
enabled=bool(data.get("enabled", True)),
|
||||||
|
version=1,
|
||||||
|
created_by=created_by,
|
||||||
|
updated_by=created_by,
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
async def update_template(
|
||||||
|
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
if row.version != expected_version:
|
||||||
|
raise ValueError("VERSION_CONFLICT")
|
||||||
|
if "name" in data:
|
||||||
|
row.name = str(data["name"])
|
||||||
|
if "title_template" in data:
|
||||||
|
row.title_template = str(data["title_template"])
|
||||||
|
if "content_template" in data:
|
||||||
|
row.content_template = str(data["content_template"])
|
||||||
|
if "tags" in data:
|
||||||
|
row.tags = list(data["tags"])
|
||||||
|
if "enabled" in data:
|
||||||
|
row.enabled = bool(data["enabled"])
|
||||||
|
row.version = row.version + 1
|
||||||
|
row.updated_by = updated_by
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
base_name = f"{row.name} - 副本"
|
||||||
|
new_name = base_name
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(NotificationTemplateRow).where(NotificationTemplateRow.name == new_name)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is None:
|
||||||
|
break
|
||||||
|
counter += 1
|
||||||
|
new_name = f"{base_name} {counter}"
|
||||||
|
|
||||||
|
new_row = NotificationTemplateRow(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
name=new_name,
|
||||||
|
title_template=row.title_template,
|
||||||
|
content_template=row.content_template,
|
||||||
|
tags=list(row.tags or []),
|
||||||
|
enabled=row.enabled,
|
||||||
|
version=1,
|
||||||
|
created_by=created_by,
|
||||||
|
updated_by=created_by,
|
||||||
|
)
|
||||||
|
session.add(new_row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(new_row)
|
||||||
|
return _to_dict(new_row)
|
||||||
|
|
||||||
|
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(NotificationTemplateRow).where(NotificationTemplateRow.name == name)
|
||||||
|
if exclude_id:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.id != exclude_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
Reference in new issue
Block a user