Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f11926db5 |
No files matched your search
@@ -10,6 +10,7 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
ListTree,
|
ListTree,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
|
FileText,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -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,14 @@ 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,706 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知模板管理(仅 admin):创建/维护消息通知模板,支持变量校验与实时预览、
|
||||||
|
* 乐观锁冲突对比(重新加载 / 覆盖保存)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Copy,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
NOTIFICATION_TEMPLATE_VARIABLES,
|
||||||
|
NotificationTemplateConflictError,
|
||||||
|
previewNotificationTemplate,
|
||||||
|
useNotificationTemplateActions,
|
||||||
|
useNotificationTemplates,
|
||||||
|
type NotificationTemplate,
|
||||||
|
type NotificationTemplateInput,
|
||||||
|
type PreviewResult,
|
||||||
|
} from "@/strategy-components/api/notification-templates";
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
titleTemplate: string;
|
||||||
|
contentTemplate: string;
|
||||||
|
tagsText: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConflictState {
|
||||||
|
server: NotificationTemplate;
|
||||||
|
mine: FormState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = (): FormState => ({
|
||||||
|
name: "",
|
||||||
|
titleTemplate: "",
|
||||||
|
contentTemplate: "",
|
||||||
|
tagsText: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function formFromTemplate(t: NotificationTemplate): FormState {
|
||||||
|
return {
|
||||||
|
name: t.name,
|
||||||
|
titleTemplate: t.titleTemplate,
|
||||||
|
contentTemplate: t.contentTemplate,
|
||||||
|
tagsText: (t.tags ?? []).join(", "),
|
||||||
|
enabled: t.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(text: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const part of text.split(/[,,]/)) {
|
||||||
|
const tag = part.trim();
|
||||||
|
if (!tag || seen.has(tag)) continue;
|
||||||
|
seen.add(tag);
|
||||||
|
out.push(tag);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInput(form: FormState): NotificationTemplateInput {
|
||||||
|
return {
|
||||||
|
name: form.name,
|
||||||
|
titleTemplate: form.titleTemplate,
|
||||||
|
contentTemplate: form.contentTemplate,
|
||||||
|
tags: parseTags(form.tagsText),
|
||||||
|
enabled: form.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREVIEW_VARS: Record<string, string> = {
|
||||||
|
userName: "张三",
|
||||||
|
taskName: "示例任务",
|
||||||
|
taskId: "T-001",
|
||||||
|
dueDate: "2026-08-10",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationTemplatesPage() {
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
|
const actions = useNotificationTemplateActions();
|
||||||
|
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [keywordApplied, setKeywordApplied] = useState("");
|
||||||
|
const [enabledFilter, setEnabledFilter] = useState<"all" | "true" | "false">("all");
|
||||||
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
keyword: keywordApplied || undefined,
|
||||||
|
enabled:
|
||||||
|
enabledFilter === "all" ? null : enabledFilter === "true" ? true : false,
|
||||||
|
tag: tagFilter.trim() || undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[keywordApplied, enabledFilter, tagFilter, page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||||
|
useNotificationTemplates(listParams);
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<NotificationTemplate | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [conflict, setConflict] = useState<ConflictState | null>(null);
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<NotificationTemplate | null>(null);
|
||||||
|
|
||||||
|
const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS);
|
||||||
|
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
|
||||||
|
const saving =
|
||||||
|
actions.create.isPending || actions.update.isPending || actions.duplicate.isPending;
|
||||||
|
|
||||||
|
// 实时预览(编辑弹窗打开时)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setPreviewLoading(true);
|
||||||
|
void previewNotificationTemplate({
|
||||||
|
titleTemplate: form.titleTemplate,
|
||||||
|
contentTemplate: form.contentTemplate,
|
||||||
|
variables: previewVars,
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setPreview(r);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setPreview(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setPreviewLoading(false);
|
||||||
|
});
|
||||||
|
}, 280);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [dialogOpen, form.titleTemplate, form.contentTemplate, previewVars]);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <Navigate to="/page/workspace/chats/new" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setSaveError(null);
|
||||||
|
setConflict(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (t: NotificationTemplate) => {
|
||||||
|
setEditing(t);
|
||||||
|
setForm(formFromTemplate(t));
|
||||||
|
setSaveError(null);
|
||||||
|
setConflict(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySearch = () => {
|
||||||
|
setPage(1);
|
||||||
|
setKeywordApplied(keyword.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (opts?: { forceVersion?: number }) => {
|
||||||
|
setSaveError(null);
|
||||||
|
const input = toInput(form);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const version = opts?.forceVersion ?? editing.version;
|
||||||
|
const updated = await actions.update.mutateAsync({
|
||||||
|
id: editing.id,
|
||||||
|
input: { ...input, expectedVersion: version },
|
||||||
|
});
|
||||||
|
setEditing(updated);
|
||||||
|
setConflict(null);
|
||||||
|
toast.success("模板已保存");
|
||||||
|
setDialogOpen(false);
|
||||||
|
} else {
|
||||||
|
await actions.create.mutateAsync(input);
|
||||||
|
toast.success("模板已创建");
|
||||||
|
setDialogOpen(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof NotificationTemplateConflictError) {
|
||||||
|
setConflict({ server: e.server, mine: form });
|
||||||
|
setSaveError("版本冲突:服务器上的模板已被他人更新,请选择重新加载或覆盖保存。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
|
setSaveError(msg);
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReloadFromServer = () => {
|
||||||
|
if (!conflict) return;
|
||||||
|
setEditing(conflict.server);
|
||||||
|
setForm(formFromTemplate(conflict.server));
|
||||||
|
setConflict(null);
|
||||||
|
setSaveError(null);
|
||||||
|
toast.info("已加载服务器最新版本");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForceOverwrite = async () => {
|
||||||
|
if (!conflict) return;
|
||||||
|
await handleSave({ forceVersion: conflict.server.version });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (t: NotificationTemplate) => {
|
||||||
|
try {
|
||||||
|
await actions.update.mutateAsync({
|
||||||
|
id: t.id,
|
||||||
|
input: {
|
||||||
|
name: t.name,
|
||||||
|
titleTemplate: t.titleTemplate,
|
||||||
|
contentTemplate: t.contentTemplate,
|
||||||
|
tags: t.tags,
|
||||||
|
enabled: !t.enabled,
|
||||||
|
expectedVersion: t.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success(t.enabled ? "已停用" : "已启用");
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof NotificationTemplateConflictError) {
|
||||||
|
toast.error("版本冲突,请刷新列表后重试");
|
||||||
|
void refetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(e instanceof Error ? e.message : "启停失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (t: NotificationTemplate) => {
|
||||||
|
try {
|
||||||
|
await actions.duplicate.mutateAsync(t.id);
|
||||||
|
toast.success("已复制");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await actions.remove.mutateAsync(deleteTarget.id);
|
||||||
|
toast.success("已删除");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
if (items.length <= 1 && page > 1) setPage((p) => p - 1);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertVariable = (name: string, field: "titleTemplate" | "contentTemplate") => {
|
||||||
|
const token = `{{${name}}}`;
|
||||||
|
setForm((f) => ({ ...f, [field]: `${f[field]}${token}` }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 p-6">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
|
||||||
|
<FileText className="size-5" />
|
||||||
|
通知模板管理
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
维护消息通知的标题/正文模板。支持变量:
|
||||||
|
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||||
|
<code key={v} className="mx-1 rounded bg-muted px-1.5 py-0.5 text-xs">
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新建模板
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
|
||||||
|
<div className="min-w-[200px] flex-1">
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">关键字</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
placeholder="搜索名称 / 标题 / 正文"
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") applySearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" onClick={applySearch}>
|
||||||
|
<Search className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-36">
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">启用状态</label>
|
||||||
|
<Select
|
||||||
|
value={enabledFilter}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setEnabledFilter(v as "all" | "true" | "false");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="true">已启用</SelectItem>
|
||||||
|
<SelectItem value="false">已停用</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">标签</label>
|
||||||
|
<Input
|
||||||
|
value={tagFilter}
|
||||||
|
placeholder="精确匹配标签"
|
||||||
|
onChange={(e) => {
|
||||||
|
setTagFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-12 text-center">
|
||||||
|
<AlertTriangle className="size-8 text-destructive" />
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message)
|
||||||
|
? "无权限访问通知模板管理"
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "加载失败"}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => void refetch()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
|
||||||
|
<FileText className="size-10 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">暂无通知模板</p>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
创建第一个模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn("overflow-hidden rounded-lg border", isFetching && "opacity-70")}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-medium">名称</th>
|
||||||
|
<th className="px-3 py-2 font-medium">标题模板</th>
|
||||||
|
<th className="px-3 py-2 font-medium">标签</th>
|
||||||
|
<th className="px-3 py-2 font-medium">版本</th>
|
||||||
|
<th className="px-3 py-2 font-medium">启用</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((t) => (
|
||||||
|
<tr key={t.id} className="border-t">
|
||||||
|
<td className="px-3 py-2.5 font-medium">{t.name}</td>
|
||||||
|
<td className="max-w-[240px] truncate px-3 py-2.5 text-muted-foreground">
|
||||||
|
{t.titleTemplate}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{t.tags.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
) : (
|
||||||
|
t.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-muted-foreground">v{t.version}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<Switch checked={t.enabled} onCheckedChange={() => void handleToggle(t)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => openEdit(t)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void handleDuplicate(t)}
|
||||||
|
title="复制"
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(t)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 新建 / 编辑 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "编辑通知模板" : "新建通知模板"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">名称</label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
maxLength={50}
|
||||||
|
placeholder="2~50 个字符"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">标题模板</label>
|
||||||
|
<Input
|
||||||
|
value={form.titleTemplate}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="如:任务 {{taskName}} 即将到期"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, titleTemplate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||||
|
onClick={() => insertVariable(v, "titleTemplate")}
|
||||||
|
>
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">正文模板</label>
|
||||||
|
<Textarea
|
||||||
|
value={form.contentTemplate}
|
||||||
|
rows={7}
|
||||||
|
maxLength={5000}
|
||||||
|
placeholder="支持 {{userName}} / {{taskName}} / {{taskId}} / {{dueDate}}"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, contentTemplate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||||
|
onClick={() => insertVariable(v, "contentTemplate")}
|
||||||
|
>
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">
|
||||||
|
标签(逗号分隔,最多 5 个)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={form.tagsText}
|
||||||
|
placeholder="提醒, 任务"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, tagsText: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={form.enabled}
|
||||||
|
onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">启用</span>
|
||||||
|
{editing && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
当前编辑版本 v{editing.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="text-sm font-medium">实时预览</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||||
|
<div key={v}>
|
||||||
|
<label className="mb-0.5 block text-[11px] text-muted-foreground">{v}</label>
|
||||||
|
<Input
|
||||||
|
value={previewVars[v] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded border bg-background p-3 text-sm">
|
||||||
|
{previewLoading && !preview ? (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
预览中…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 font-medium">{preview?.title || "(标题预览)"}</div>
|
||||||
|
<div className="whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{preview?.content || "(正文预览)"}
|
||||||
|
</div>
|
||||||
|
{preview && preview.missingVariables.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
|
缺少变量:{preview.missingVariables.join(", ")}(保留原占位符)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conflict && (
|
||||||
|
<div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
版本冲突 — 请选择如何处理
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="rounded border bg-background p-2 text-xs">
|
||||||
|
<div className="mb-1 font-medium">服务器最新版本 (v{conflict.server.version})</div>
|
||||||
|
<div className="text-muted-foreground">名称:{conflict.server.name}</div>
|
||||||
|
<div className="text-muted-foreground">标题:{conflict.server.titleTemplate}</div>
|
||||||
|
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{conflict.server.contentTemplate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border bg-background p-2 text-xs">
|
||||||
|
<div className="mb-1 font-medium">我的编辑内容</div>
|
||||||
|
<div className="text-muted-foreground">名称:{conflict.mine.name}</div>
|
||||||
|
<div className="text-muted-foreground">标题:{conflict.mine.titleTemplate}</div>
|
||||||
|
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{conflict.mine.contentTemplate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={handleReloadFromServer}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={saving} onClick={() => void handleForceOverwrite()}>
|
||||||
|
覆盖保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={saving || !!conflict} onClick={() => void handleSave()}>
|
||||||
|
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
确定删除模板「{deleteTarget?.name}」?此操作不可恢复。
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={actions.remove.isPending}
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
>
|
||||||
|
{actions.remove.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
|
|||||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||||
import SkillAddressesPage from "./SkillAddressesPage";
|
import SkillAddressesPage from "./SkillAddressesPage";
|
||||||
import TaskButtonsPage from "./TaskButtonsPage";
|
import TaskButtonsPage from "./TaskButtonsPage";
|
||||||
|
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||||
import PositionRolesPage from "./PositionRolesPage";
|
import PositionRolesPage from "./PositionRolesPage";
|
||||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||||
@@ -438,6 +439,16 @@ export default function StrategyRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/notification-templates"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<NotificationTemplatesPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="admin/position-roles"
|
path="admin/position-roles"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* 通知模板管理 API 客户端 + React Query hooks。
|
||||||
|
*
|
||||||
|
* 后端:`/api/notification-templates`(见 app/gateway/routers/notification_templates.py)。
|
||||||
|
* 全局共享;读/写均仅 admin。snake_case ⇄ camelCase 在此边界收敛。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
|
|
||||||
|
export const NOTIFICATION_TEMPLATE_VARIABLES = [
|
||||||
|
"userName",
|
||||||
|
"taskName",
|
||||||
|
"taskId",
|
||||||
|
"dueDate",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NotificationTemplateVariable =
|
||||||
|
(typeof NOTIFICATION_TEMPLATE_VARIABLES)[number];
|
||||||
|
|
||||||
|
export interface NotificationTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
titleTemplate: string;
|
||||||
|
contentTemplate: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationTemplateInput {
|
||||||
|
name: string;
|
||||||
|
titleTemplate: string;
|
||||||
|
contentTemplate: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationTemplateUpdateInput extends NotificationTemplateInput {
|
||||||
|
expectedVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListNotificationTemplatesParams {
|
||||||
|
keyword?: string;
|
||||||
|
enabled?: boolean | null;
|
||||||
|
tag?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationTemplateListResult {
|
||||||
|
items: NotificationTemplate[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResult {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
missingVariables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 409 乐观锁冲突:携带服务端最新完整记录。 */
|
||||||
|
export class NotificationTemplateConflictError extends Error {
|
||||||
|
readonly server: NotificationTemplate;
|
||||||
|
|
||||||
|
constructor(server: NotificationTemplate, message = "版本冲突") {
|
||||||
|
super(message);
|
||||||
|
this.name = "NotificationTemplateConflictError";
|
||||||
|
this.server = server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = () => `${getBackendBaseURL()}/api/notification-templates`;
|
||||||
|
|
||||||
|
interface ServerTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
title_template: string;
|
||||||
|
content_template: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
created_by?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTemplate(s: ServerTemplate): NotificationTemplate {
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
titleTemplate: s.title_template,
|
||||||
|
contentTemplate: s.content_template,
|
||||||
|
tags: s.tags ?? [],
|
||||||
|
enabled: s.enabled ?? true,
|
||||||
|
version: s.version ?? 1,
|
||||||
|
createdBy: s.created_by ?? null,
|
||||||
|
createdAt: s.created_at ?? null,
|
||||||
|
updatedAt: s.updated_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBody(input: NotificationTemplateInput & { expectedVersion?: number }) {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: input.name,
|
||||||
|
title_template: input.titleTemplate,
|
||||||
|
content_template: input.contentTemplate,
|
||||||
|
tags: input.tags,
|
||||||
|
enabled: input.enabled,
|
||||||
|
};
|
||||||
|
if (input.expectedVersion !== undefined) {
|
||||||
|
body.expected_version = input.expectedVersion;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailMessage(data: unknown, fallback: string): string {
|
||||||
|
if (!data || typeof data !== "object" || !("detail" in data)) return fallback;
|
||||||
|
const detail = (data as { detail: unknown }).detail;
|
||||||
|
if (typeof detail === "string") return detail;
|
||||||
|
if (detail && typeof detail === "object" && "message" in detail) {
|
||||||
|
const msg = (detail as { message: unknown }).message;
|
||||||
|
if (typeof msg === "string") return msg;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const data = await res.json().catch(() => null as unknown);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 409 && data && typeof data === "object" && "detail" in data) {
|
||||||
|
const detail = (data as { detail: unknown }).detail;
|
||||||
|
if (detail && typeof detail === "object" && "server" in detail) {
|
||||||
|
const server = (detail as { server: ServerTemplate }).server;
|
||||||
|
if (server && typeof server === "object" && "id" in server) {
|
||||||
|
throw new NotificationTemplateConflictError(
|
||||||
|
toTemplate(server),
|
||||||
|
detailMessage(data, "版本冲突"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(detailMessage(data, `${ctx} failed (${res.status})`));
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listNotificationTemplates(
|
||||||
|
params: ListNotificationTemplatesParams = {},
|
||||||
|
): Promise<NotificationTemplateListResult> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.keyword) qs.set("keyword", params.keyword);
|
||||||
|
if (params.enabled === true || params.enabled === false) {
|
||||||
|
qs.set("enabled", String(params.enabled));
|
||||||
|
}
|
||||||
|
if (params.tag) qs.set("tag", params.tag);
|
||||||
|
if (params.page) qs.set("page", String(params.page));
|
||||||
|
if (params.pageSize) qs.set("page_size", String(params.pageSize));
|
||||||
|
const query = qs.toString();
|
||||||
|
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
|
||||||
|
const data = await readJson<{
|
||||||
|
items: ServerTemplate[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}>(res, "list notification templates");
|
||||||
|
const result: NotificationTemplateListResult = {
|
||||||
|
items: (data.items ?? []).map(toTemplate),
|
||||||
|
total: data.total ?? 0,
|
||||||
|
page: data.page ?? 1,
|
||||||
|
pageSize: data.page_size ?? params.pageSize ?? 20,
|
||||||
|
};
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.search,
|
||||||
|
optDetail: "CM助手-全量-通知模板-列表",
|
||||||
|
url: `${BASE()}${query ? `?${query}` : ""}`,
|
||||||
|
reqParam: params,
|
||||||
|
returnParam: { total: result.total, page: result.page },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationTemplate(id: string): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||||
|
const tpl = toTemplate(await readJson<ServerTemplate>(res, "get notification template"));
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.view,
|
||||||
|
optDetail: "CM助手-全量-通知模板-查看",
|
||||||
|
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||||
|
reqParam: { id },
|
||||||
|
returnParam: tpl,
|
||||||
|
});
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNotificationTemplate(
|
||||||
|
input: NotificationTemplateInput,
|
||||||
|
): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(BASE(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(toBody(input)),
|
||||||
|
});
|
||||||
|
const tpl = toTemplate(await readJson<ServerTemplate>(res, "create notification template"));
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.create,
|
||||||
|
optDetail: "CM助手-全量-通知模板-创建",
|
||||||
|
url: BASE(),
|
||||||
|
reqParam: input,
|
||||||
|
returnParam: tpl,
|
||||||
|
});
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotificationTemplate(
|
||||||
|
id: string,
|
||||||
|
input: NotificationTemplateUpdateInput,
|
||||||
|
): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(toBody(input)),
|
||||||
|
});
|
||||||
|
const tpl = toTemplate(await readJson<ServerTemplate>(res, "update notification template"));
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.edit,
|
||||||
|
optDetail: "CM助手-全量-通知模板-修改",
|
||||||
|
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||||
|
reqParam: { id, input },
|
||||||
|
returnParam: tpl,
|
||||||
|
});
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotificationTemplate(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
await readJson<void>(res, "delete notification template");
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.delete,
|
||||||
|
optDetail: "CM助手-全量-通知模板-删除",
|
||||||
|
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||||
|
reqParam: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function duplicateNotificationTemplate(
|
||||||
|
id: string,
|
||||||
|
): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/duplicate`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const tpl = toTemplate(await readJson<ServerTemplate>(res, "duplicate notification template"));
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.create,
|
||||||
|
optDetail: "CM助手-全量-通知模板-复制",
|
||||||
|
url: `${BASE()}/${encodeURIComponent(id)}/duplicate`,
|
||||||
|
reqParam: { id },
|
||||||
|
returnParam: tpl,
|
||||||
|
});
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewNotificationTemplate(input: {
|
||||||
|
titleTemplate: string;
|
||||||
|
contentTemplate: string;
|
||||||
|
variables: Record<string, string>;
|
||||||
|
}): Promise<PreviewResult> {
|
||||||
|
const res = await apiFetch(`${BASE()}/preview`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title_template: input.titleTemplate,
|
||||||
|
content_template: input.contentTemplate,
|
||||||
|
variables: input.variables,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await readJson<{
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
missing_variables: string[];
|
||||||
|
}>(res, "preview notification template");
|
||||||
|
return {
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
missingVariables: data.missing_variables ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "notification-templates";
|
||||||
|
|
||||||
|
export function useNotificationTemplates(params: ListNotificationTemplatesParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [
|
||||||
|
KEY,
|
||||||
|
"list",
|
||||||
|
params.keyword ?? "",
|
||||||
|
params.enabled === true ? "1" : params.enabled === false ? "0" : "",
|
||||||
|
params.tag ?? "",
|
||||||
|
params.page ?? 1,
|
||||||
|
params.pageSize ?? 20,
|
||||||
|
],
|
||||||
|
queryFn: () => listNotificationTemplates(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationTemplateActions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: [KEY] });
|
||||||
|
return {
|
||||||
|
create: useMutation({ mutationFn: createNotificationTemplate, onSuccess: invalidate }),
|
||||||
|
update: useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: string; input: NotificationTemplateUpdateInput }) =>
|
||||||
|
updateNotificationTemplate(id, input),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
}),
|
||||||
|
remove: useMutation({ mutationFn: deleteNotificationTemplate, onSuccess: invalidate }),
|
||||||
|
duplicate: useMutation({
|
||||||
|
mutationFn: duplicateNotificationTemplate,
|
||||||
|
onSuccess: invalidate,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -51,13 +51,14 @@ from app.gateway.routers import (
|
|||||||
models,
|
models,
|
||||||
multi_agent,
|
multi_agent,
|
||||||
notifications,
|
notifications,
|
||||||
|
notification_templates,
|
||||||
open_3qfx,
|
open_3qfx,
|
||||||
open_chat,
|
open_chat,
|
||||||
parallel_agent_tasks,
|
parallel_agent_tasks,
|
||||||
parallel_agents,
|
parallel_agents,
|
||||||
position_roundtable,
|
position_roundtable,
|
||||||
positions,
|
positions,
|
||||||
position_roles,
|
position_roles,
|
||||||
public_config,
|
public_config,
|
||||||
public_embed,
|
public_embed,
|
||||||
public_leaderboard,
|
public_leaderboard,
|
||||||
@@ -858,9 +859,9 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
|
|||||||
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
|
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
|
||||||
return
|
return
|
||||||
|
|
||||||
store = getattr(app.state, "concurrency_sample_store", None)
|
store = getattr(app.state, "concurrency_sample_store", None)
|
||||||
run_mgr = getattr(app.state, "run_manager", None)
|
run_mgr = getattr(app.state, "run_manager", None)
|
||||||
concurrency_gate = getattr(app.state, "concurrency_gate", None)
|
concurrency_gate = getattr(app.state, "concurrency_gate", None)
|
||||||
if store is None or run_mgr is None:
|
if store is None or run_mgr is None:
|
||||||
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
|
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
|
||||||
return
|
return
|
||||||
@@ -881,16 +882,16 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
|
|||||||
try:
|
try:
|
||||||
# Runtime toggle (admin can turn sampling off without a restart).
|
# Runtime toggle (admin can turn sampling off without a restart).
|
||||||
# mtime-cached read, so this is cheap every tick.
|
# mtime-cached read, so this is cheap every tick.
|
||||||
from deerflow.config.system_settings import get_concurrency_monitor_settings
|
from deerflow.config.system_settings import get_concurrency_monitor_settings
|
||||||
|
|
||||||
if get_concurrency_monitor_settings().enabled:
|
if get_concurrency_monitor_settings().enabled:
|
||||||
active_count = None
|
active_count = None
|
||||||
if concurrency_gate is not None:
|
if concurrency_gate is not None:
|
||||||
active_count = await concurrency_gate.count_active()
|
active_count = await concurrency_gate.count_active()
|
||||||
if active_count is None:
|
if active_count is None:
|
||||||
active = await run_mgr.list_active()
|
active = await run_mgr.list_active()
|
||||||
active_count = len(active)
|
active_count = len(active)
|
||||||
await store.record(active_count)
|
await store.record(active_count)
|
||||||
ticks += 1
|
ticks += 1
|
||||||
if ticks % purge_every_ticks == 0:
|
if ticks % purge_every_ticks == 0:
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
@@ -1338,18 +1339,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
|
v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
|
||||||
return v in ("1", "true", "yes", "on") or is_auth_disabled()
|
return v in ("1", "true", "yes", "on") or is_auth_disabled()
|
||||||
|
|
||||||
# The legacy TaskCOP Vue application uses Vite's default development port.
|
# The legacy TaskCOP Vue application uses Vite's default development port.
|
||||||
# Keep these origins available even when deployments configure additional
|
# Keep these origins available even when deployments configure additional
|
||||||
# origins through GATEWAY_CORS_ORIGINS.
|
# origins through GATEWAY_CORS_ORIGINS.
|
||||||
taskcop_vite_origins = [
|
taskcop_vite_origins = [
|
||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"http://127.0.0.1:8080",
|
"http://127.0.0.1:8080",
|
||||||
]
|
]
|
||||||
|
|
||||||
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
|
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
|
||||||
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
|
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
|
||||||
if cors_origins:
|
if cors_origins:
|
||||||
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
|
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
|
||||||
|
|
||||||
# Headers the LangGraph SDK / browser clients need to read from cross-origin
|
# Headers the LangGraph SDK / browser clients need to read from cross-origin
|
||||||
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
|
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
|
||||||
@@ -1408,10 +1409,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
"http://localhost:5174",
|
"http://localhost:5174",
|
||||||
"http://127.0.0.1:5174",
|
"http://127.0.0.1:5174",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
*taskcop_vite_origins,
|
*taskcop_vite_origins,
|
||||||
]
|
]
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No CORS configuration found; enabling local dev origins only: %s. "
|
"No CORS configuration found; enabling local dev origins only: %s. "
|
||||||
"Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
|
"Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
|
||||||
@@ -1471,25 +1472,25 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
||||||
app.include_router(flow_extract.router)
|
app.include_router(flow_extract.router)
|
||||||
|
|
||||||
# Roundtable-planning business chains at /api/roundtable-chains
|
# Roundtable-planning business chains at /api/roundtable-chains
|
||||||
app.include_router(roundtable_chains.router)
|
app.include_router(roundtable_chains.router)
|
||||||
|
|
||||||
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
|
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
|
||||||
# Canonical position-workspace persistence API.
|
# Canonical position-workspace persistence API.
|
||||||
app.include_router(position_roundtable.router, prefix="/api")
|
app.include_router(position_roundtable.router, prefix="/api")
|
||||||
# Compatibility route for intranet gateways whose allowlist only exposes
|
# Compatibility route for intranet gateways whose allowlist only exposes
|
||||||
# the established `/api/multi-agent/*` namespace.
|
# the established `/api/multi-agent/*` namespace.
|
||||||
app.include_router(
|
app.include_router(
|
||||||
position_roundtable.router,
|
position_roundtable.router,
|
||||||
prefix="/api/multi-agent",
|
prefix="/api/multi-agent",
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Global business ? agent / chain mapping at /api/business-mapping
|
# Global business ? agent / chain mapping at /api/business-mapping
|
||||||
app.include_router(business_mapping.router)
|
app.include_router(business_mapping.router)
|
||||||
|
|
||||||
# Position-roundtable work-view role configuration at /api/position-roles.
|
# Position-roundtable work-view role configuration at /api/position-roles.
|
||||||
app.include_router(position_roles.router)
|
app.include_router(position_roles.router)
|
||||||
|
|
||||||
# Light-app registry (?????) at /api/light-apps
|
# Light-app registry (?????) at /api/light-apps
|
||||||
app.include_router(light_apps.router)
|
app.include_router(light_apps.router)
|
||||||
@@ -1508,6 +1509,7 @@ 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)
|
||||||
|
app.include_router(notification_templates.router)
|
||||||
|
|
||||||
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
||||||
app.include_router(taskcop_tasks.router)
|
app.include_router(taskcop_tasks.router)
|
||||||
|
|||||||
@@ -255,23 +255,23 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
|
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||||
|
|
||||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||||
|
|
||||||
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
||||||
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
||||||
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
||||||
|
|
||||||
app.state.position_roundtable_store = make_position_roundtable_store(sf)
|
app.state.position_roundtable_store = make_position_roundtable_store(sf)
|
||||||
|
|
||||||
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
|
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
|
||||||
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
|
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
|
||||||
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
|
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
|
||||||
from deerflow.persistence.position_roles import make_position_role_store
|
from deerflow.persistence.position_roles import make_position_role_store
|
||||||
|
|
||||||
app.state.position_role_store = make_position_role_store(sf)
|
app.state.position_role_store = make_position_role_store(sf)
|
||||||
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
|
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
|
||||||
|
|
||||||
from deerflow.persistence.business_mapping import make_business_mapping_store
|
from deerflow.persistence.business_mapping import make_business_mapping_store
|
||||||
|
|
||||||
@@ -295,17 +295,21 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
from deerflow.persistence.task_buttons import make_task_button_store
|
from deerflow.persistence.task_buttons import make_task_button_store
|
||||||
|
|
||||||
app.state.task_button_store = make_task_button_store(sf)
|
app.state.task_button_store = make_task_button_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
from deerflow.persistence.notification_templates import make_notification_template_store
|
||||||
|
|
||||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
app.state.notification_template_store = make_notification_template_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.task_reports import make_task_report_store
|
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||||
|
|
||||||
app.state.task_report_store = make_task_report_store(sf)
|
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
from deerflow.persistence.task_reports import make_task_report_store
|
||||||
|
|
||||||
|
app.state.task_report_store = make_task_report_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
||||||
|
|
||||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||||
|
|
||||||
@@ -389,11 +393,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
|
|
||||||
# RunManager with store backing for persistence
|
# RunManager with store backing for persistence
|
||||||
app.state.run_manager = RunManager(store=app.state.run_store)
|
app.state.run_manager = RunManager(store=app.state.run_store)
|
||||||
from deerflow.runtime import make_redis_concurrency_gate
|
from deerflow.runtime import make_redis_concurrency_gate
|
||||||
|
|
||||||
app.state.concurrency_gate = make_redis_concurrency_gate(config)
|
app.state.concurrency_gate = make_redis_concurrency_gate(config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -407,10 +411,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
await queue.stop()
|
await queue.stop()
|
||||||
set_auto_ingest_queue(None)
|
set_auto_ingest_queue(None)
|
||||||
await close_engine()
|
await close_engine()
|
||||||
gate = getattr(app.state, "concurrency_gate", None)
|
gate = getattr(app.state, "concurrency_gate", None)
|
||||||
if gate is not None:
|
if gate is not None:
|
||||||
await gate.aclose()
|
await gate.aclose()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -436,12 +440,12 @@ get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run
|
|||||||
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
|
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
|
||||||
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
|
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
|
||||||
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
|
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
|
||||||
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
||||||
|
|
||||||
|
|
||||||
def get_concurrency_gate(request: Request):
|
def get_concurrency_gate(request: Request):
|
||||||
"""Return the optional cross-worker concurrency gate."""
|
"""Return the optional cross-worker concurrency gate."""
|
||||||
return getattr(request.app.state, "concurrency_gate", None)
|
return getattr(request.app.state, "concurrency_gate", None)
|
||||||
|
|
||||||
|
|
||||||
def get_store(request: Request):
|
def get_store(request: Request):
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""Notification template management API (通知模板管理).
|
||||||
|
|
||||||
|
Global / shared templates for admin-authored notification messages. Every
|
||||||
|
endpoint (including reads) requires ``system_role == "admin"``.
|
||||||
|
|
||||||
|
Routes (prefix ``/api/notification-templates``):
|
||||||
|
GET "" list (keyword / enabled / tag / page / page_size)
|
||||||
|
POST "" create
|
||||||
|
GET "/{id}" read one
|
||||||
|
PUT "/{id}" update (optimistic lock via expected_version)
|
||||||
|
DELETE "/{id}" delete
|
||||||
|
POST "/{id}/duplicate" duplicate
|
||||||
|
POST "/preview" render title/content with variables
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request, Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||||||
|
from deerflow.persistence.notification_templates import (
|
||||||
|
DuplicateNotificationTemplateNameError,
|
||||||
|
NotificationTemplateStore,
|
||||||
|
TemplateVariableError,
|
||||||
|
VersionConflictError,
|
||||||
|
preview_templates,
|
||||||
|
validate_templates,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
title_template: str
|
||||||
|
content_template: str
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
enabled: bool = True
|
||||||
|
version: int = 1
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: datetime | str | None = None
|
||||||
|
updated_at: datetime | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateListResponse(BaseModel):
|
||||||
|
items: list[NotificationTemplateResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=80)
|
||||||
|
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 NotificationTemplateUpdateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=80)
|
||||||
|
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 PreviewRequest(BaseModel):
|
||||||
|
title_template: str = Field(..., min_length=0, max_length=120)
|
||||||
|
content_template: str = Field(..., min_length=0, max_length=5000)
|
||||||
|
variables: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewResponse(BaseModel):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
missing_variables: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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) -> None:
|
||||||
|
"""Reject non-admins. Lets the call through when auth is disabled."""
|
||||||
|
user = await get_optional_user_from_request(request)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
if getattr(user, "system_role", None) != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="通知模板管理仅限管理员")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_name(name: str) -> str:
|
||||||
|
cleaned = (name or "").strip()
|
||||||
|
if not (2 <= len(cleaned) <= 50):
|
||||||
|
raise HTTPException(status_code=422, detail="名称去除首尾空格后长度须为 2~50")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tags(tags: list[str] | None) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in tags or []:
|
||||||
|
tag = (raw or "").strip()
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
if not (1 <= len(tag) <= 20):
|
||||||
|
raise HTTPException(status_code=422, detail="单个标签长度须为 1~20")
|
||||||
|
if tag in seen:
|
||||||
|
continue
|
||||||
|
seen.add(tag)
|
||||||
|
out.append(tag)
|
||||||
|
if len(out) > 5:
|
||||||
|
raise HTTPException(status_code=422, detail="标签最多 5 个")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_lengths(title: str, content: str) -> None:
|
||||||
|
if not (1 <= len(title) <= 120):
|
||||||
|
raise HTTPException(status_code=422, detail="标题模板长度须为 1~120")
|
||||||
|
if not (1 <= len(content) <= 5000):
|
||||||
|
raise HTTPException(status_code=422, detail="正文模板长度须为 1~5000")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_variables(title: str, content: str) -> None:
|
||||||
|
try:
|
||||||
|
validate_templates(title, content)
|
||||||
|
except TemplateVariableError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=exc.message) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(row: dict) -> NotificationTemplateResponse:
|
||||||
|
return NotificationTemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
async def _unique_duplicate_name(store: NotificationTemplateStore, original: str) -> str:
|
||||||
|
base = f"{original} - 副本"
|
||||||
|
if not await store.name_exists(base):
|
||||||
|
return base
|
||||||
|
n = 2
|
||||||
|
while True:
|
||||||
|
candidate = f"{original} - 副本 {n}"
|
||||||
|
if not await store.name_exists(candidate):
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=NotificationTemplateListResponse)
|
||||||
|
async def list_notification_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),
|
||||||
|
) -> NotificationTemplateListResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
items, total = await store.list_templates(
|
||||||
|
keyword=keyword,
|
||||||
|
enabled=enabled,
|
||||||
|
tag=tag,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return NotificationTemplateListResponse(
|
||||||
|
items=[_to_response(r) for r in items],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview", response_model=PreviewResponse)
|
||||||
|
async def preview_notification_template(request: Request, body: PreviewRequest) -> PreviewResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
result = preview_templates(body.title_template, body.content_template, body.variables)
|
||||||
|
return PreviewResponse(**result) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=NotificationTemplateResponse, status_code=201)
|
||||||
|
async def create_notification_template(
|
||||||
|
request: Request,
|
||||||
|
body: NotificationTemplateCreateRequest,
|
||||||
|
) -> NotificationTemplateResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
name = _normalize_name(body.name)
|
||||||
|
tags = _normalize_tags(body.tags)
|
||||||
|
_validate_lengths(body.title_template, body.content_template)
|
||||||
|
_validate_variables(body.title_template, body.content_template)
|
||||||
|
store = _get_store(request)
|
||||||
|
user_id = await get_current_user(request)
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except DuplicateNotificationTemplateNameError:
|
||||||
|
raise HTTPException(status_code=409, detail="模板名称已存在")
|
||||||
|
return _to_response(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
|
||||||
|
async def get_notification_template(request: Request, template_id: str) -> NotificationTemplateResponse:
|
||||||
|
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 _to_response(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}", response_model=NotificationTemplateResponse)
|
||||||
|
async def update_notification_template(
|
||||||
|
request: Request,
|
||||||
|
template_id: str,
|
||||||
|
body: NotificationTemplateUpdateRequest,
|
||||||
|
) -> NotificationTemplateResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
name = _normalize_name(body.name)
|
||||||
|
tags = _normalize_tags(body.tags)
|
||||||
|
_validate_lengths(body.title_template, body.content_template)
|
||||||
|
_validate_variables(body.title_template, body.content_template)
|
||||||
|
store = _get_store(request)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
except VersionConflictError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={"message": "版本冲突,请重新加载或覆盖保存", "server": exc.server},
|
||||||
|
) from exc
|
||||||
|
except DuplicateNotificationTemplateNameError:
|
||||||
|
raise HTTPException(status_code=409, detail="模板名称已存在")
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
return _to_response(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}", status_code=204)
|
||||||
|
async def delete_notification_template(request: Request, template_id: str) -> Response:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
deleted = await store.delete_template(template_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{template_id}/duplicate", response_model=NotificationTemplateResponse, status_code=201)
|
||||||
|
async def duplicate_notification_template(
|
||||||
|
request: Request,
|
||||||
|
template_id: str,
|
||||||
|
) -> NotificationTemplateResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
source = await store.get_template(template_id)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
new_name = await _unique_duplicate_name(store, source["name"])
|
||||||
|
user_id = await get_current_user(request)
|
||||||
|
try:
|
||||||
|
row = await store.create_template(
|
||||||
|
{
|
||||||
|
"name": new_name,
|
||||||
|
"title_template": source["title_template"],
|
||||||
|
"content_template": source["content_template"],
|
||||||
|
"tags": list(source.get("tags") or []),
|
||||||
|
"enabled": source.get("enabled", True),
|
||||||
|
"created_by": user_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except DuplicateNotificationTemplateNameError:
|
||||||
|
raise HTTPException(status_code=409, detail="模板名称已存在")
|
||||||
|
return _to_response(row)
|
||||||
+2
@@ -45,6 +45,7 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
|||||||
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
|
||||||
@@ -110,6 +111,7 @@ __all__ = [
|
|||||||
"LlmCallMetricRow",
|
"LlmCallMetricRow",
|
||||||
"MenuOverrideRow",
|
"MenuOverrideRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
|
"NotificationTemplateRow",
|
||||||
"ParallelAgentTaskRow",
|
"ParallelAgentTaskRow",
|
||||||
"PositionRoundtableNodeRow",
|
"PositionRoundtableNodeRow",
|
||||||
"PositionRoundtableSessionRow",
|
"PositionRoundtableSessionRow",
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
"""Notification template persistence (通知模板管理)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import (
|
||||||
|
DuplicateNotificationTemplateNameError,
|
||||||
|
NotificationTemplateStore,
|
||||||
|
VersionConflictError,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.notification_templates.memory import MemoryNotificationTemplateStore
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
|
from deerflow.persistence.notification_templates.sql import NotificationTemplateRepository
|
||||||
|
from deerflow.persistence.notification_templates.variables import (
|
||||||
|
ALLOWED_VARIABLES,
|
||||||
|
TemplateVariableError,
|
||||||
|
preview_templates,
|
||||||
|
validate_templates,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALLOWED_VARIABLES",
|
||||||
|
"DuplicateNotificationTemplateNameError",
|
||||||
|
"MemoryNotificationTemplateStore",
|
||||||
|
"NotificationTemplateRepository",
|
||||||
|
"NotificationTemplateRow",
|
||||||
|
"NotificationTemplateStore",
|
||||||
|
"TemplateVariableError",
|
||||||
|
"VersionConflictError",
|
||||||
|
"make_notification_template_store",
|
||||||
|
"preview_templates",
|
||||||
|
"validate_templates",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_notification_template_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemoryNotificationTemplateStore()
|
||||||
|
return NotificationTemplateRepository(session_factory)
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
"""Abstract notification-template persistence API.
|
||||||
|
|
||||||
|
Global / shared store: no ``user_id`` scoping. The router gates every endpoint
|
||||||
|
behind admin. ``created_by`` is audit-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateNotificationTemplateNameError(ValueError):
|
||||||
|
"""Raised when creating/updating would collide on the unique name."""
|
||||||
|
|
||||||
|
|
||||||
|
class VersionConflictError(Exception):
|
||||||
|
"""Raised when ``expected_version`` does not match the stored version."""
|
||||||
|
|
||||||
|
def __init__(self, server: dict[str, Any]) -> None:
|
||||||
|
super().__init__("version conflict")
|
||||||
|
self.server = server
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
"""Return ``(items, total)`` newest-first with optional filters."""
|
||||||
|
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]) -> dict[str, Any]:
|
||||||
|
"""Create a template. Raises ``DuplicateNotificationTemplateNameError``."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_template(
|
||||||
|
self,
|
||||||
|
template_id: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
*,
|
||||||
|
expected_version: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Update with optimistic lock. Returns None if missing.
|
||||||
|
|
||||||
|
Raises ``VersionConflictError`` (with ``.server``) on mismatch, or
|
||||||
|
``DuplicateNotificationTemplateNameError`` on a name clash.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
"""In-memory notification-template store (tests / no-DB)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import (
|
||||||
|
DuplicateNotificationTemplateNameError,
|
||||||
|
NotificationTemplateStore,
|
||||||
|
VersionConflictError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_WRITABLE = {
|
||||||
|
"name",
|
||||||
|
"title_template",
|
||||||
|
"content_template",
|
||||||
|
"tags",
|
||||||
|
"enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryNotificationTemplateStore(NotificationTemplateStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._rows: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(row)
|
||||||
|
out["tags"] = list(out.get("tags") or [])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
|
||||||
|
return any(r["name"] == name and r["id"] != exclude_id for r in self._rows.values())
|
||||||
|
|
||||||
|
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
|
||||||
|
return self._name_taken(name, exclude_id=exclude_id)
|
||||||
|
|
||||||
|
def _matches(
|
||||||
|
self,
|
||||||
|
row: dict[str, Any],
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
enabled: bool | None,
|
||||||
|
tag: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if enabled is not None and bool(row.get("enabled")) is not enabled:
|
||||||
|
return False
|
||||||
|
if tag is not None and tag not in (row.get("tags") or []):
|
||||||
|
return False
|
||||||
|
if keyword:
|
||||||
|
needle = keyword.strip().lower()
|
||||||
|
if needle:
|
||||||
|
hay = " ".join(
|
||||||
|
[
|
||||||
|
str(row.get("name") or ""),
|
||||||
|
str(row.get("title_template") or ""),
|
||||||
|
str(row.get("content_template") or ""),
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
if needle not in hay:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
rows = [
|
||||||
|
r
|
||||||
|
for r in self._rows.values()
|
||||||
|
if self._matches(r, keyword=keyword, enabled=enabled, tag=tag)
|
||||||
|
]
|
||||||
|
rows.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||||
|
total = len(rows)
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, page_size)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
items = [self._public(r) for r in rows[start : start + page_size]]
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
return self._public(row) if row is not None else None
|
||||||
|
|
||||||
|
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self._name_taken(data["name"]):
|
||||||
|
raise DuplicateNotificationTemplateNameError(data["name"])
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
template_id = data.get("id") or uuid.uuid4().hex[:12]
|
||||||
|
row = {
|
||||||
|
"id": template_id,
|
||||||
|
"name": data["name"],
|
||||||
|
"title_template": data["title_template"],
|
||||||
|
"content_template": data["content_template"],
|
||||||
|
"tags": list(data.get("tags") or []),
|
||||||
|
"enabled": data.get("enabled", True),
|
||||||
|
"version": 1,
|
||||||
|
"created_by": data.get("created_by"),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
self._rows[template_id] = row
|
||||||
|
return self._public(row)
|
||||||
|
|
||||||
|
async def update_template(
|
||||||
|
self,
|
||||||
|
template_id: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
*,
|
||||||
|
expected_version: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if int(row["version"]) != int(expected_version):
|
||||||
|
raise VersionConflictError(self._public(row))
|
||||||
|
new_name = data.get("name")
|
||||||
|
if new_name is not None and new_name != row["name"] and self._name_taken(new_name, exclude_id=template_id):
|
||||||
|
raise DuplicateNotificationTemplateNameError(new_name)
|
||||||
|
for key, value in data.items():
|
||||||
|
if key in _WRITABLE:
|
||||||
|
row[key] = value
|
||||||
|
row["version"] = int(row["version"]) + 1
|
||||||
|
row["updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
return self._public(row)
|
||||||
|
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
return self._rows.pop(template_id, None) is not None
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"""ORM model for notification templates (通知模板管理).
|
||||||
|
|
||||||
|
Global / shared — no ``user_id`` scoping. ``created_by`` is audit-only.
|
||||||
|
All reads/writes are admin-gated at the router layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
|
||||||
|
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"
|
||||||
|
__table_args__ = (UniqueConstraint("name", name="uq_notification_templates_name"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
title_template: Mapped[str] = mapped_column(String(120))
|
||||||
|
content_template: Mapped[str] = mapped_column(Text)
|
||||||
|
tags: Mapped[list] = mapped_column(PortableJSON(), default=list)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
created_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),
|
||||||
|
)
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
"""SQLAlchemy-backed notification-template store (global / shared)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import (
|
||||||
|
DuplicateNotificationTemplateNameError,
|
||||||
|
NotificationTemplateStore,
|
||||||
|
VersionConflictError,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
|
|
||||||
|
_WRITABLE = {
|
||||||
|
"name",
|
||||||
|
"title_template",
|
||||||
|
"content_template",
|
||||||
|
"tags",
|
||||||
|
"enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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"] = list(data.get("tags") or [])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateRepository(NotificationTemplateStore):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
return await self._name_taken(session, name, exclude_id=exclude_id)
|
||||||
|
|
||||||
|
async def _name_taken(self, session: AsyncSession, name: str, *, exclude_id: str | None = None) -> bool:
|
||||||
|
stmt = select(NotificationTemplateRow.id).where(NotificationTemplateRow.name == name)
|
||||||
|
if exclude_id is not None:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.id != exclude_id)
|
||||||
|
result = await session.execute(stmt.limit(1))
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
def _filter_stmt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
enabled: bool | None,
|
||||||
|
tag: str | None,
|
||||||
|
):
|
||||||
|
stmt = select(NotificationTemplateRow)
|
||||||
|
if enabled is not None:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.enabled.is_(enabled))
|
||||||
|
if keyword:
|
||||||
|
needle = f"%{keyword.strip()}%"
|
||||||
|
if keyword.strip():
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
NotificationTemplateRow.name.ilike(needle),
|
||||||
|
NotificationTemplateRow.title_template.ilike(needle),
|
||||||
|
NotificationTemplateRow.content_template.ilike(needle),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Tag filter is applied in Python (PortableJSON differs across backends).
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = max(1, page_size)
|
||||||
|
stmt = self._filter_stmt(keyword=keyword, enabled=enabled, tag=tag)
|
||||||
|
stmt = stmt.order_by(NotificationTemplateRow.created_at.desc())
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
rows = [_to_dict(r) for r in result.scalars()]
|
||||||
|
if tag is not None:
|
||||||
|
rows = [r for r in rows if tag in (r.get("tags") or [])]
|
||||||
|
total = len(rows)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
return rows[start : start + page_size], total
|
||||||
|
|
||||||
|
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 is not None else None
|
||||||
|
|
||||||
|
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = NotificationTemplateRow(
|
||||||
|
id=data.get("id") or uuid.uuid4().hex[:12],
|
||||||
|
name=data["name"],
|
||||||
|
title_template=data["title_template"],
|
||||||
|
content_template=data["content_template"],
|
||||||
|
tags=list(data.get("tags") or []),
|
||||||
|
enabled=data.get("enabled", True),
|
||||||
|
version=1,
|
||||||
|
created_by=data.get("created_by"),
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
if await self._name_taken(session, row.name):
|
||||||
|
raise DuplicateNotificationTemplateNameError(row.name)
|
||||||
|
session.add(row)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise DuplicateNotificationTemplateNameError(row.name) from exc
|
||||||
|
await session.refresh(row)
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
async def update_template(
|
||||||
|
self,
|
||||||
|
template_id: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
*,
|
||||||
|
expected_version: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if int(row.version) != int(expected_version):
|
||||||
|
raise VersionConflictError(_to_dict(row))
|
||||||
|
new_name = data.get("name")
|
||||||
|
if new_name is not None and new_name != row.name and await self._name_taken(session, new_name, exclude_id=template_id):
|
||||||
|
raise DuplicateNotificationTemplateNameError(new_name)
|
||||||
|
for key, value in data.items():
|
||||||
|
if key in _WRITABLE:
|
||||||
|
setattr(row, key, value)
|
||||||
|
row.version = int(row.version) + 1
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise DuplicateNotificationTemplateNameError(new_name or row.name) from exc
|
||||||
|
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
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""Template variable validation and preview helpers for notification templates.
|
||||||
|
|
||||||
|
Supported placeholders (exact names, no spaces):
|
||||||
|
{{userName}} {{taskName}} {{taskId}} {{dueDate}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
ALLOWED_VARIABLES: frozenset[str] = frozenset({"userName", "taskName", "taskId", "dueDate"})
|
||||||
|
|
||||||
|
# Captures the inner of a well-formed {{...}} pair (no nested braces).
|
||||||
|
_VAR_RE = re.compile(r"\{\{([^{}]*)\}\}")
|
||||||
|
# Valid identifier: letters then letters/digits (no spaces / punctuation).
|
||||||
|
_IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateVariableError(ValueError):
|
||||||
|
"""Raised when a template contains an invalid / unsupported / unclosed variable."""
|
||||||
|
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def validate_template_text(text: str) -> None:
|
||||||
|
"""Validate all ``{{var}}`` occurrences in *text*.
|
||||||
|
|
||||||
|
Raises ``TemplateVariableError`` with a Chinese message pointing at the
|
||||||
|
offending variable or position.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
start = text.find("{{", i)
|
||||||
|
if start < 0:
|
||||||
|
break
|
||||||
|
end = text.find("}}", start + 2)
|
||||||
|
if end < 0:
|
||||||
|
raise TemplateVariableError(f"未闭合的变量(位置 {start})")
|
||||||
|
inner = text[start + 2 : end]
|
||||||
|
token = "{{" + inner + "}}"
|
||||||
|
if not inner or not _IDENT_RE.fullmatch(inner):
|
||||||
|
raise TemplateVariableError(f"变量格式错误:{token}")
|
||||||
|
if inner not in ALLOWED_VARIABLES:
|
||||||
|
raise TemplateVariableError(f"不支持的变量:{token}")
|
||||||
|
i = end + 2
|
||||||
|
|
||||||
|
|
||||||
|
def validate_templates(*texts: str) -> None:
|
||||||
|
"""Validate every template string; first error wins."""
|
||||||
|
for text in texts:
|
||||||
|
validate_template_text(text or "")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_variables(text: str) -> list[str]:
|
||||||
|
"""Return ordered unique allowed-looking variable names found in *text*."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for match in _VAR_RE.finditer(text or ""):
|
||||||
|
name = match.group(1)
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
out.append(name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(text: str, variables: dict[str, str] | None) -> tuple[str, list[str]]:
|
||||||
|
"""Replace known placeholders; keep missing ones and report their names.
|
||||||
|
|
||||||
|
Returns ``(rendered, missing_variables)``. Missing names are unique and
|
||||||
|
appear in first-seen order.
|
||||||
|
"""
|
||||||
|
vars_map = variables or {}
|
||||||
|
missing: list[str] = []
|
||||||
|
seen_missing: set[str] = set()
|
||||||
|
|
||||||
|
def _repl(match: re.Match[str]) -> str:
|
||||||
|
name = match.group(1)
|
||||||
|
if name in vars_map and vars_map[name] is not None:
|
||||||
|
return str(vars_map[name])
|
||||||
|
if name not in seen_missing:
|
||||||
|
seen_missing.add(name)
|
||||||
|
missing.append(name)
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
rendered = _VAR_RE.sub(_repl, text or "")
|
||||||
|
return rendered, missing
|
||||||
|
|
||||||
|
|
||||||
|
def preview_templates(
|
||||||
|
title_template: str,
|
||||||
|
content_template: str,
|
||||||
|
variables: dict[str, str] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Render title + content; merge missing variable lists (order-preserving)."""
|
||||||
|
title, missing_title = render_template(title_template, variables)
|
||||||
|
content, missing_content = render_template(content_template, variables)
|
||||||
|
seen: set[str] = set()
|
||||||
|
missing: list[str] = []
|
||||||
|
for name in missing_title + missing_content:
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
missing.append(name)
|
||||||
|
return {"title": title, "content": content, "missing_variables": missing}
|
||||||
Reference in new issue
Block a user