Jelajahi Sumber

feat(approval): P2-12 核心三页移动端适配 + P4-16 延伸(通知模板化/渠道管理页)

后端
- 新增实体 ApprovalFlowNotifyTemplate(全局默认 + BizType 级覆盖,支持变量插值,启动时幂等播种 9 类通知默认模板)
- 新增实体 ApprovalFlowNotifyConfig(通知渠道 DB 覆盖层)
- 新增服务 FlowNotifyTemplateService / FlowNotifyConfigService(列表/保存/删除 + 渲染 + 缓存 + 有效配置聚合)
- 重构 FlowNotifyService 使用模板渲染与 DB 覆盖 + JSON 兜底;贯穿 bizType 参数
- FlowEngineService 调用点同步补传 instance.BizType 到所有 Notify* 方法
- Startup 初始化两张新表 + 调用 EnsureSystemTemplates
- 菜单种子新增"通知配置"(1310300010106)

前端
- 新增"通知配置"管理页(模板 Tab + 渠道 Tab):支持 BizType 级覆盖新增/编辑、渠道启停/凭据运行时修改(即改即生效)、SignalR 站内测试发送、查看模板可用变量
- 新增 theme/media/approval.scss,为 .approval-center / .approval-panel / .flow-statistics / .notify-config-container 定义 $xs / $sm 断点
- 审批中心 3 个列表、统计看板的次要列标注 mobile-hide,≤576px 时隐藏
- ApprovalPanel 操作按钮改 50%/50% 换行,textarea + 常用意见改竖排
- api.ts 增加模板/渠道 7 个接口

文档
- 功能说明:新增通知模板化、通知渠道管理页、移动端适配条目
- 集成指南:11.1.1 模板化、11.1.2 渠道运行时可改、配置步骤第 5/6 步、附录 A 移动端说明
- 综合优化方案:记录第七批

迁移
- doc/migrations/2026-04-16_approval_flow_notify_tpl_cfg.sql
- tools/_apply_notify_tpl_cfg.py / _insert_notify_config_menu.py
- tools/_verify_notify_tpl_cfg.py(5/5 PASS)

版本:Web 2.4.83 → 2.4.84;后端 1.0.50 → 1.0.51

Made-with: Cursor
skygu 1 bulan lalu
induk
melakukan
ec2bf8fabf
24 mengubah file dengan 1843 tambahan dan 168 penghapusan
  1. 207 0
      Web/src/theme/media/approval.scss
  2. 1 0
      Web/src/theme/media/media.scss
  3. 68 0
      Web/src/views/approvalFlow/api.ts
  4. 3 3
      Web/src/views/approvalFlow/center/components/DoneList.vue
  5. 4 4
      Web/src/views/approvalFlow/center/components/InitiatedList.vue
  6. 4 4
      Web/src/views/approvalFlow/center/components/PendingList.vue
  7. 1 1
      Web/src/views/approvalFlow/center/index.vue
  8. 409 0
      Web/src/views/approvalFlow/notifyConfig/index.vue
  9. 24 24
      Web/src/views/approvalFlow/statistics/index.vue
  10. 65 0
      ai-dop-platform/tools/_apply_notify_tpl_cfg.py
  11. 97 0
      ai-dop-platform/tools/_insert_notify_config_menu.py
  12. 109 0
      ai-dop-platform/tools/_verify_notify_tpl_cfg.py
  13. 49 0
      doc/migrations/2026-04-16_approval_flow_notify_tpl_cfg.sql
  14. 28 3
      doc/plan/审批流-综合优化方案.md
  15. 23 4
      doc/审批流功能说明.md
  16. 62 1
      doc/审批流集成开发指南.md
  17. 52 0
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Entity/ApprovalFlowNotifyConfig.cs
  18. 59 0
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Entity/ApprovalFlowNotifyTemplate.cs
  19. 1 0
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/SeedData/SysMenuSeedData.cs
  20. 12 12
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs
  21. 237 0
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowNotify/FlowNotifyConfigService.cs
  22. 92 112
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowNotify/FlowNotifyService.cs
  23. 230 0
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowNotify/FlowNotifyTemplateService.cs
  24. 6 0
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Startup.cs

+ 207 - 0
Web/src/theme/media/approval.scss

@@ -0,0 +1,207 @@
+@use './index.scss' as *;
+
+/* 审批流移动端适配 (P2-12 核心三页: 审批中心 / ApprovalPanel / 统计看板;附带 notifyConfig 页)
+ * 断点:$xs(576px) 以下进入手机窄屏态;$sm(768px) 以下进入平板态。
+ * 仅作用于相关容器类,避免污染全局。
+------------------------------- */
+
+/* ── 平板态(≤768px):工具条多行、表格内紧凑、信息列隐藏次要 ── */
+@media screen and (max-width: $sm) {
+	.approval-center,
+	.approval-panel,
+	.flow-statistics,
+	.notify-config-container {
+		.el-card__body {
+			padding: 10px 8px !important;
+		}
+		.el-form--inline .el-form-item {
+			margin-right: 8px;
+			margin-bottom: 6px;
+		}
+		.el-tabs__item {
+			padding: 0 10px !important;
+		}
+	}
+
+	/* 审批统计 4 个数字卡片在平板下改两列 */
+	.flow-statistics {
+		.summary-cards .el-col {
+			flex: 0 0 50% !important;
+			max-width: 50% !important;
+			margin-bottom: 8px;
+		}
+	}
+}
+
+/* ── 手机窄屏态(≤576px):表格→卡片、按钮全宽、分页简化 ── */
+@media screen and (max-width: $xs) {
+	.approval-center,
+	.approval-panel,
+	.flow-statistics,
+	.notify-config-container {
+		padding: 4px !important;
+
+		/* 隐藏次要列(统一用类名标注) */
+		.mobile-hide {
+			display: none !important;
+		}
+
+		/* 查询表单全宽竖排 */
+		.el-form--inline {
+			.el-form-item {
+				display: block !important;
+				width: 100%;
+				margin-right: 0;
+			}
+			.el-form-item__content {
+				width: 100%;
+				.el-select,
+				.el-input,
+				.el-date-editor,
+				.el-tree-select {
+					width: 100% !important;
+				}
+			}
+			/* 查询/重置按钮全宽并列 */
+			.form-actions {
+				display: flex;
+				gap: 8px;
+				> .el-button {
+					flex: 1;
+				}
+			}
+		}
+
+		/* 分页控件瘦身:仅保留翻页与 total */
+		.el-pagination {
+			.el-pagination__sizes,
+			.el-pagination__jump {
+				display: none !important;
+			}
+			button,
+			.el-pager li {
+				min-width: 28px !important;
+				height: 28px !important;
+				line-height: 28px !important;
+			}
+		}
+
+		/* tabs 左右滑动 */
+		.el-tabs__nav {
+			white-space: nowrap;
+			overflow-x: auto;
+		}
+
+		/* 弹窗自适应宽度,保留 16px 左右安全边距 */
+		.el-dialog {
+			width: calc(100vw - 32px) !important;
+			margin: 32px 16px !important;
+			max-width: 100vw !important;
+		}
+	}
+
+	/* 审批中心 统计 列表:表格模式 → 卡片模式 */
+	.approval-center {
+		.approval-mobile-card {
+			display: block;
+			padding: 10px 12px;
+			margin-bottom: 8px;
+			border: 1px solid var(--el-border-color-lighter);
+			border-radius: 6px;
+			background: var(--el-bg-color);
+			box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+
+			.amc-title {
+				font-weight: 600;
+				font-size: 14px;
+				line-height: 1.4;
+				margin-bottom: 6px;
+				color: var(--el-text-color-primary);
+			}
+			.amc-meta {
+				display: flex;
+				flex-wrap: wrap;
+				gap: 6px 12px;
+				color: var(--el-text-color-regular);
+				font-size: 12px;
+				margin-bottom: 8px;
+			}
+			.amc-actions {
+				display: flex;
+				gap: 6px;
+				flex-wrap: wrap;
+				> .el-button {
+					flex: 1 1 auto;
+					min-width: 72px;
+				}
+			}
+		}
+		/* 窄屏隐藏整张表格,仅用卡片呈现 */
+		.desktop-only-table {
+			display: none !important;
+		}
+	}
+
+	/* ApprovalPanel(审批详情+操作区) */
+	.approval-panel {
+		/* el-descriptions 由 2 列改 1 列(太挤) */
+		.el-descriptions__body .el-descriptions__table {
+			table-layout: fixed;
+		}
+		.el-descriptions .el-descriptions-item__label,
+		.el-descriptions .el-descriptions-item__content {
+			padding: 4px 8px !important;
+			font-size: 12px;
+		}
+		/* 操作按钮横排 → 等分换行 */
+		.btn-row {
+			gap: 6px !important;
+			.el-button {
+				flex: 1 1 calc(50% - 6px);
+				min-width: 0;
+				margin-left: 0 !important;
+			}
+		}
+		/* textarea 与"常用意见"按钮改两行布局 */
+		.panel-section > div[style*='gap: 8px'] {
+			flex-direction: column !important;
+			align-items: stretch !important;
+		}
+	}
+
+	/* 审批统计:4 卡片改 2 列,图表容器缩高;隐藏不重要的列 */
+	.flow-statistics {
+		.summary-cards .el-col {
+			flex: 0 0 50% !important;
+			max-width: 50% !important;
+			margin-bottom: 8px;
+			.summary-card {
+				padding: 10px 8px !important;
+				.value {
+					font-size: 18px !important;
+				}
+				.label {
+					font-size: 12px !important;
+				}
+				.sub {
+					font-size: 11px !important;
+				}
+			}
+		}
+		/* ECharts 容器高度压缩(统计页使用 .chart-box,原 inline 260/280 在手机下收紧到 200) */
+		.chart-box {
+			height: 200px !important;
+		}
+	}
+
+	/* 通知配置页 */
+	.notify-config-container {
+		.cfg-tip {
+			.hint {
+				display: block;
+				margin-left: 0;
+				margin-top: 4px;
+			}
+		}
+	}
+}

+ 1 - 0
Web/src/theme/media/media.scss

@@ -11,3 +11,4 @@
 @use './dialog.scss' as *;
 @use './cityLinkage.scss' as *;
 @use './date.scss' as *;
+@use './approval.scss' as *;

+ 68 - 0
Web/src/views/approvalFlow/api.ts

@@ -193,3 +193,71 @@ export function statTopSlowNodes(data: FlowStatisticsInput) {
 export function getFlowListForFilter() {
 	return axiosInstance.post<any>(`${BASE}/approvalFlow/page`, { page: 1, pageSize: 500 });
 }
+
+// ── 通知模板 (P4-16 延伸) ──
+
+export interface NotifyTemplate {
+	id?: number;
+	notifyType: string;
+	bizType: string;
+	title: string;
+	content: string;
+	isEnabled: boolean;
+	isSystem?: boolean;
+	remark?: string;
+}
+
+export function getNotifyTemplateList(params?: { notifyType?: string; bizType?: string }) {
+	return axiosInstance.get<any>(`${BASE}/flowNotifyTemplate/list`, { params });
+}
+
+export function saveNotifyTemplate(data: {
+	notifyType: string;
+	bizType?: string;
+	title: string;
+	content: string;
+	isEnabled?: boolean;
+	remark?: string;
+}) {
+	return axiosInstance.post<any>(`${BASE}/flowNotifyTemplate/save`, data);
+}
+
+export function deleteNotifyTemplate(data: { id: number }) {
+	return axiosInstance.post(`${BASE}/flowNotifyTemplate/delete`, data);
+}
+
+export function getNotifyTemplateVariables() {
+	return axiosInstance.get<any>(`${BASE}/flowNotifyTemplate/variables`);
+}
+
+// ── 通知渠道配置 (P4-16 延伸) ──
+
+export interface NotifyChannel {
+	id: number;
+	channelKey: string;
+	enabled: boolean;
+	webhookUrl?: string;
+	secret?: string;
+	templateId?: string;
+	remark?: string;
+	source: 'DB' | 'JSON';
+}
+
+export function getNotifyConfigList() {
+	return axiosInstance.get<any>(`${BASE}/flowNotifyConfig/list`);
+}
+
+export function saveNotifyConfig(data: {
+	channelKey: string;
+	enabled: boolean;
+	webhookUrl?: string;
+	secret?: string;
+	templateId?: string;
+	remark?: string;
+}) {
+	return axiosInstance.post<any>(`${BASE}/flowNotifyConfig/save`, data);
+}
+
+export function testSendNotify() {
+	return axiosInstance.post<any>(`${BASE}/flowNotifyConfig/testSend`);
+}

+ 3 - 3
Web/src/views/approvalFlow/center/components/DoneList.vue

@@ -2,15 +2,15 @@
 	<el-table :data="data" v-loading="loading" border style="width: 100%">
 		<el-table-column type="index" label="#" width="55" align="center" />
 		<el-table-column prop="title" label="流程标题" show-overflow-tooltip />
-		<el-table-column prop="bizType" label="业务类型" width="140" />
+		<el-table-column prop="bizType" label="业务类型" width="140" class-name="mobile-hide" label-class-name="mobile-hide" />
 		<el-table-column prop="nodeName" label="审批节点" width="140" />
 		<el-table-column prop="status" label="审批结果" width="100" align="center">
 			<template #default="{ row }">
 				<el-tag :type="taskStatusTag(row.status)" size="small">{{ taskStatusLabel(row.status) }}</el-tag>
 			</template>
 		</el-table-column>
-		<el-table-column prop="comment" label="意见" show-overflow-tooltip />
-		<el-table-column prop="actionTime" label="操作时间" width="180" />
+		<el-table-column prop="comment" label="意见" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
+		<el-table-column prop="actionTime" label="操作时间" width="180" class-name="mobile-hide" label-class-name="mobile-hide" />
 		<el-table-column label="操作" width="80" align="center" fixed="right">
 			<template #default="{ row }">
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>

+ 4 - 4
Web/src/views/approvalFlow/center/components/InitiatedList.vue

@@ -2,9 +2,9 @@
 	<el-table :data="data" v-loading="loading" border style="width: 100%">
 		<el-table-column type="index" label="#" width="55" align="center" />
 		<el-table-column prop="title" label="流程标题" show-overflow-tooltip />
-		<el-table-column prop="bizType" label="业务类型" width="140" />
-		<el-table-column prop="bizNo" label="业务单号" width="160" show-overflow-tooltip />
-		<el-table-column prop="currentAssigneeName" label="当前审批人" width="140" show-overflow-tooltip>
+		<el-table-column prop="bizType" label="业务类型" width="140" class-name="mobile-hide" label-class-name="mobile-hide" />
+		<el-table-column prop="bizNo" label="业务单号" width="160" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
+		<el-table-column prop="currentAssigneeName" label="当前审批人" width="140" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide">
 			<template #default="{ row }">
 				<span v-if="row.currentAssigneeName">{{ row.currentAssigneeName }}</span>
 				<span v-else style="color: #c0c4cc">-</span>
@@ -15,7 +15,7 @@
 				<el-tag :type="instanceStatusTag(row.status)" size="small">{{ instanceStatusLabel(row.status) }}</el-tag>
 			</template>
 		</el-table-column>
-		<el-table-column prop="startTime" label="发起时间" width="180" />
+		<el-table-column prop="startTime" label="发起时间" width="180" class-name="mobile-hide" label-class-name="mobile-hide" />
 		<el-table-column label="操作" width="180" align="center" fixed="right">
 			<template #default="{ row }">
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>

+ 4 - 4
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -14,11 +14,11 @@
 					<span>{{ row.title }}</span>
 				</template>
 			</el-table-column>
-			<el-table-column prop="bizType" label="业务类型" width="140" />
-			<el-table-column prop="bizNo" label="业务单号" width="160" show-overflow-tooltip />
+			<el-table-column prop="bizType" label="业务类型" width="140" class-name="mobile-hide" label-class-name="mobile-hide" />
+			<el-table-column prop="bizNo" label="业务单号" width="160" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
 			<el-table-column prop="nodeName" label="当前节点" width="140" />
-			<el-table-column prop="initiatorName" label="发起人" width="100" />
-			<el-table-column prop="createTime" label="接收时间" width="180" />
+			<el-table-column prop="initiatorName" label="发起人" width="100" class-name="mobile-hide" label-class-name="mobile-hide" />
+			<el-table-column prop="createTime" label="接收时间" width="180" class-name="mobile-hide" label-class-name="mobile-hide" />
 			<el-table-column label="操作" width="240" align="center" fixed="right">
 				<template #default="{ row }">
 					<el-button size="small" type="success" text @click="emit('approve', row)">同意</el-button>

+ 1 - 1
Web/src/views/approvalFlow/center/index.vue

@@ -1,5 +1,5 @@
 <template>
-	<div class="approval-center-container">
+	<div class="approval-center-container approval-center">
 		<el-card shadow="hover" :body-style="{ paddingBottom: '0' }">
 			<el-tabs v-model="activeTab" @tab-change="onTabChange">
 				<el-tab-pane label="我的待办" name="pending">

+ 409 - 0
Web/src/views/approvalFlow/notifyConfig/index.vue

@@ -0,0 +1,409 @@
+<template>
+	<div class="notify-config-container">
+		<el-card shadow="hover" :body-style="{ padding: '12px 16px' }">
+			<el-tabs v-model="activeTab">
+				<!-- ── 通知模板 ── -->
+				<el-tab-pane label="通知模板" name="template">
+					<div class="tpl-toolbar">
+						<el-form :inline="true" :model="tplQuery" @submit.prevent>
+							<el-form-item label="通知类型">
+								<el-select v-model="tplQuery.notifyType" placeholder="全部" clearable style="width: 160px">
+									<el-option v-for="t in notifyTypes" :key="t.value" :label="`${t.label} (${t.value})`" :value="t.value" />
+								</el-select>
+							</el-form-item>
+							<el-form-item label="业务类型">
+								<el-select v-model="tplQuery.bizType" placeholder="全部(含全局默认)" clearable filterable style="width: 220px">
+									<el-option label="全局默认(空)" value="" />
+									<el-option v-for="b in bizTypes" :key="b.code" :label="`${b.name} (${b.code})`" :value="b.code" />
+								</el-select>
+							</el-form-item>
+							<el-form-item>
+								<el-button type="primary" @click="loadTemplates">查询</el-button>
+								<el-button type="success" @click="openAddTemplate">新增覆盖</el-button>
+								<el-button text @click="showVariables = true">查看可用变量</el-button>
+							</el-form-item>
+						</el-form>
+					</div>
+
+					<el-table :data="templates" v-loading="tplLoading" border stripe size="small">
+						<el-table-column prop="notifyType" label="通知类型" width="120" />
+						<el-table-column prop="bizType" label="业务类型" width="160">
+							<template #default="{ row }">
+								<el-tag size="small" :type="row.bizType ? 'warning' : 'info'">
+									{{ row.bizType || '全局默认' }}
+								</el-tag>
+							</template>
+						</el-table-column>
+						<el-table-column prop="title" label="标题模板" min-width="220" show-overflow-tooltip />
+						<el-table-column prop="content" label="正文模板" min-width="280" show-overflow-tooltip />
+						<el-table-column label="启用" width="80" align="center">
+							<template #default="{ row }">
+								<el-tag size="small" :type="row.isEnabled ? 'success' : 'info'">{{ row.isEnabled ? '启用' : '停用' }}</el-tag>
+							</template>
+						</el-table-column>
+						<el-table-column label="预置" width="70" align="center">
+							<template #default="{ row }">
+								<el-tag v-if="row.isSystem" size="small" type="primary">系统</el-tag>
+								<span v-else>-</span>
+							</template>
+						</el-table-column>
+						<el-table-column label="操作" width="160" align="center" fixed="right">
+							<template #default="{ row }">
+								<el-button size="small" text @click="openEditTemplate(row)">编辑</el-button>
+								<el-button size="small" text type="danger" @click="removeTemplate(row)" :disabled="row.isSystem">删除</el-button>
+							</template>
+						</el-table-column>
+					</el-table>
+				</el-tab-pane>
+
+				<!-- ── 通知渠道 ── -->
+				<el-tab-pane label="通知渠道" name="channel">
+					<div class="cfg-tip">
+						<el-alert type="info" :closable="false" show-icon>
+							<template #title>
+								启用状态、凭据保存到数据库后立即生效(DB 覆盖层 + JSON 兜底)。
+								Source="DB" 表示已在库中保存过;"JSON" 表示使用 ApprovalFlow.json 兜底值,可点"编辑"落盘。
+								外部渠道需先在 <code>ApprovalFlow.json</code> 打开 <code>Notify.XXX=true</code> 或在此保存 Enabled=true。
+							</template>
+						</el-alert>
+						<div style="margin-top: 8px">
+							<el-button type="warning" plain size="small" @click="handleTestSend">发送 SignalR 站内测试消息</el-button>
+							<span class="hint">仅测试 SignalR 链路:会向当前登录用户推送一条测试通知。</span>
+						</div>
+					</div>
+
+					<el-table :data="channels" v-loading="cfgLoading" border stripe size="small" style="margin-top: 10px">
+						<el-table-column prop="channelKey" label="渠道" width="130">
+							<template #default="{ row }">
+								<el-tag size="small" :type="channelTagType(row.channelKey)">{{ channelLabel(row.channelKey) }}</el-tag>
+							</template>
+						</el-table-column>
+						<el-table-column label="启用" width="90" align="center">
+							<template #default="{ row }">
+								<el-tag size="small" :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag>
+							</template>
+						</el-table-column>
+						<el-table-column prop="webhookUrl" label="Webhook URL" min-width="260" show-overflow-tooltip>
+							<template #default="{ row }">{{ row.webhookUrl || '-' }}</template>
+						</el-table-column>
+						<el-table-column prop="templateId" label="模板Id" width="130">
+							<template #default="{ row }">{{ row.templateId || '-' }}</template>
+						</el-table-column>
+						<el-table-column prop="source" label="来源" width="90" align="center">
+							<template #default="{ row }">
+								<el-tag size="small" :type="row.source === 'DB' ? 'success' : ''">{{ row.source }}</el-tag>
+							</template>
+						</el-table-column>
+						<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
+						<el-table-column label="操作" width="100" align="center" fixed="right">
+							<template #default="{ row }">
+								<el-button size="small" text @click="openEditChannel(row)">编辑</el-button>
+							</template>
+						</el-table-column>
+					</el-table>
+				</el-tab-pane>
+			</el-tabs>
+		</el-card>
+
+		<!-- 模板编辑弹窗 -->
+		<el-dialog v-model="tplDialogVisible" :title="tplIsEdit ? '编辑通知模板' : '新增通知模板'" width="620px" destroy-on-close>
+			<el-form ref="tplFormRef" :model="tplForm" :rules="tplRules" label-width="100px">
+				<el-form-item label="通知类型" prop="notifyType">
+					<el-select v-model="tplForm.notifyType" placeholder="请选择" style="width: 100%" :disabled="tplIsEdit && tplForm.isSystem">
+						<el-option v-for="t in notifyTypes" :key="t.value" :label="`${t.label} (${t.value})`" :value="t.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="业务类型" prop="bizType">
+					<el-select v-model="tplForm.bizType" placeholder="全局默认留空;选业务类型则仅对该业务覆盖" clearable filterable style="width: 100%" :disabled="tplIsEdit && tplForm.isSystem">
+						<el-option label="全局默认(空)" value="" />
+						<el-option v-for="b in bizTypes" :key="b.code" :label="`${b.name} (${b.code})`" :value="b.code" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="标题" prop="title">
+					<el-input v-model="tplForm.title" placeholder="支持变量 {title}" maxlength="256" show-word-limit />
+				</el-form-item>
+				<el-form-item label="正文" prop="content">
+					<el-input v-model="tplForm.content" type="textarea" :rows="3" placeholder="支持变量 {title} {nodeName} 等" maxlength="1024" show-word-limit />
+				</el-form-item>
+				<el-form-item label="启用">
+					<el-switch v-model="tplForm.isEnabled" />
+				</el-form-item>
+				<el-form-item label="备注">
+					<el-input v-model="tplForm.remark" maxlength="256" show-word-limit />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="tplDialogVisible = false">取消</el-button>
+				<el-button type="primary" @click="handleSaveTemplate">保存</el-button>
+			</template>
+		</el-dialog>
+
+		<!-- 渠道编辑弹窗 -->
+		<el-dialog v-model="cfgDialogVisible" title="编辑通知渠道配置" width="560px" destroy-on-close>
+			<el-form ref="cfgFormRef" :model="cfgForm" label-width="110px">
+				<el-form-item label="渠道">
+					<el-tag size="small">{{ channelLabel(cfgForm.channelKey) }}({{ cfgForm.channelKey }})</el-tag>
+				</el-form-item>
+				<el-form-item label="启用">
+					<el-switch v-model="cfgForm.enabled" />
+				</el-form-item>
+				<el-form-item v-if="needWebhook(cfgForm.channelKey)" label="Webhook URL">
+					<el-input v-model="cfgForm.webhookUrl" placeholder="群机器人 Webhook URL" maxlength="512" show-word-limit />
+				</el-form-item>
+				<el-form-item v-if="cfgForm.channelKey === 'DingTalk'" label="加签 Secret">
+					<el-input v-model="cfgForm.secret" placeholder="钉钉加签 Secret(可选)" maxlength="128" show-word-limit />
+				</el-form-item>
+				<el-form-item v-if="cfgForm.channelKey === 'Sms'" label="短信模板Id">
+					<el-input v-model="cfgForm.templateId" placeholder="运营商短信模板 Id" maxlength="64" show-word-limit />
+				</el-form-item>
+				<el-form-item label="备注">
+					<el-input v-model="cfgForm.remark" maxlength="256" show-word-limit />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="cfgDialogVisible = false">取消</el-button>
+				<el-button type="primary" @click="handleSaveChannel">保存</el-button>
+			</template>
+		</el-dialog>
+
+		<!-- 变量说明 -->
+		<el-dialog v-model="showVariables" title="通知模板可用变量" width="620px">
+			<el-table :data="variables" size="small" border>
+				<el-table-column prop="key" label="变量" width="150">
+					<template #default="{ row }">
+						<el-tag size="small">{{ '{' + row.key + '}' }}</el-tag>
+					</template>
+				</el-table-column>
+				<el-table-column prop="description" label="含义" />
+				<el-table-column prop="appliedTo" label="适用通知类型" width="220" />
+			</el-table>
+		</el-dialog>
+	</div>
+</template>
+
+<script setup lang="ts" name="approvalFlowNotifyConfig">
+import { ref, onMounted, reactive } from 'vue';
+import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
+import {
+	getNotifyTemplateList,
+	saveNotifyTemplate,
+	deleteNotifyTemplate,
+	getNotifyTemplateVariables,
+	getNotifyConfigList,
+	saveNotifyConfig,
+	testSendNotify,
+	getBizTypeList,
+	NotifyTemplate,
+	NotifyChannel,
+} from '/@/views/approvalFlow/api';
+
+const activeTab = ref<'template' | 'channel'>('template');
+
+// ── 通知类型列表(与后端 FlowNotificationTypeEnum 保持一致) ──
+const notifyTypes = [
+	{ value: 'NewTask', label: '待审批通知' },
+	{ value: 'Urge', label: '催办' },
+	{ value: 'FlowCompleted', label: '流程完成' },
+	{ value: 'Transferred', label: '转办' },
+	{ value: 'Returned', label: '退回' },
+	{ value: 'AddSign', label: '加签' },
+	{ value: 'Withdrawn', label: '撤回' },
+	{ value: 'Escalated', label: '升级' },
+	{ value: 'Timeout', label: '超时提醒' },
+];
+
+const bizTypes = ref<Array<{ code: string; name: string }>>([]);
+
+// ═══════════════════════ 模板 Tab ═══════════════════════
+
+const tplQuery = reactive<{ notifyType: string; bizType: string }>({ notifyType: '', bizType: '' });
+const templates = ref<NotifyTemplate[]>([]);
+const tplLoading = ref(false);
+
+async function loadTemplates() {
+	tplLoading.value = true;
+	try {
+		const res: any = await getNotifyTemplateList({
+			notifyType: tplQuery.notifyType || undefined,
+			bizType: tplQuery.bizType === '' ? undefined : tplQuery.bizType,
+		});
+		templates.value = res?.data?.result ?? res?.data ?? [];
+	} finally {
+		tplLoading.value = false;
+	}
+}
+
+const tplDialogVisible = ref(false);
+const tplIsEdit = ref(false);
+const tplForm = reactive<any>({
+	id: undefined,
+	notifyType: 'NewTask',
+	bizType: '',
+	title: '',
+	content: '',
+	isEnabled: true,
+	remark: '',
+	isSystem: false,
+});
+const tplRules = {
+	notifyType: [{ required: true, message: '必填', trigger: 'change' }],
+	title: [{ required: true, message: '必填', trigger: 'blur' }],
+	content: [{ required: true, message: '必填', trigger: 'blur' }],
+};
+const tplFormRef = ref<FormInstance>();
+
+function openAddTemplate() {
+	tplIsEdit.value = false;
+	Object.assign(tplForm, { id: undefined, notifyType: 'NewTask', bizType: '', title: '', content: '', isEnabled: true, remark: '', isSystem: false });
+	tplDialogVisible.value = true;
+}
+
+function openEditTemplate(row: NotifyTemplate) {
+	tplIsEdit.value = true;
+	Object.assign(tplForm, { ...row });
+	tplDialogVisible.value = true;
+}
+
+async function handleSaveTemplate() {
+	await tplFormRef.value?.validate();
+	await saveNotifyTemplate({
+		notifyType: tplForm.notifyType,
+		bizType: tplForm.bizType || '',
+		title: tplForm.title,
+		content: tplForm.content,
+		isEnabled: tplForm.isEnabled,
+		remark: tplForm.remark,
+	});
+	ElMessage.success('已保存');
+	tplDialogVisible.value = false;
+	await loadTemplates();
+}
+
+async function removeTemplate(row: NotifyTemplate) {
+	await ElMessageBox.confirm(`确定删除模板【${row.notifyType} / ${row.bizType || '全局'}】?`, '提示', { type: 'warning' });
+	await deleteNotifyTemplate({ id: row.id! });
+	ElMessage.success('已删除');
+	await loadTemplates();
+}
+
+const variables = ref<any[]>([]);
+const showVariables = ref(false);
+async function loadVariables() {
+	const res: any = await getNotifyTemplateVariables();
+	variables.value = res?.data?.result ?? res?.data ?? [];
+}
+
+// ═══════════════════════ 渠道 Tab ═══════════════════════
+
+const channels = ref<NotifyChannel[]>([]);
+const cfgLoading = ref(false);
+
+async function loadChannels() {
+	cfgLoading.value = true;
+	try {
+		const res: any = await getNotifyConfigList();
+		channels.value = res?.data?.result ?? res?.data ?? [];
+	} finally {
+		cfgLoading.value = false;
+	}
+}
+
+const cfgDialogVisible = ref(false);
+const cfgForm = reactive<any>({
+	channelKey: '',
+	enabled: false,
+	webhookUrl: '',
+	secret: '',
+	templateId: '',
+	remark: '',
+});
+const cfgFormRef = ref<FormInstance>();
+
+function openEditChannel(row: NotifyChannel) {
+	Object.assign(cfgForm, {
+		channelKey: row.channelKey,
+		enabled: row.enabled,
+		webhookUrl: row.webhookUrl ?? '',
+		secret: row.secret ?? '',
+		templateId: row.templateId ?? '',
+		remark: row.remark ?? '',
+	});
+	cfgDialogVisible.value = true;
+}
+
+async function handleSaveChannel() {
+	await saveNotifyConfig({
+		channelKey: cfgForm.channelKey,
+		enabled: cfgForm.enabled,
+		webhookUrl: cfgForm.webhookUrl || undefined,
+		secret: cfgForm.secret || undefined,
+		templateId: cfgForm.templateId || undefined,
+		remark: cfgForm.remark || undefined,
+	});
+	ElMessage.success('已保存(立即生效)');
+	cfgDialogVisible.value = false;
+	await loadChannels();
+}
+
+async function handleTestSend() {
+	const res: any = await testSendNotify();
+	const ok = res?.data?.result ?? res?.data;
+	if (ok) ElMessage.success('已向您发送一条 SignalR 测试通知');
+	else ElMessage.warning('当前登录会话未在线,未发送(请确认已登录前台并持有活动 SignalR 连接)');
+}
+
+function needWebhook(key: string) {
+	return key === 'DingTalk' || key === 'WorkWeixin';
+}
+
+function channelLabel(key: string) {
+	return (
+		{
+			SignalR: '站内消息',
+			Email: '邮件',
+			Sms: '短信',
+			DingTalk: '钉钉',
+			WorkWeixin: '企业微信',
+		}[key] ?? key
+	);
+}
+
+function channelTagType(key: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' {
+	return (
+		({
+			SignalR: 'primary',
+			Email: 'success',
+			Sms: 'warning',
+			DingTalk: 'info',
+			WorkWeixin: 'info',
+		} as const)[key] ?? 'info'
+	);
+}
+
+// ── 初始化 ──
+onMounted(async () => {
+	try {
+		const res: any = await getBizTypeList();
+		bizTypes.value = (res?.data?.result ?? res?.data ?? []).map((b: any) => ({ code: b.code, name: b.name }));
+	} catch (e) {
+		// ignore
+	}
+	await Promise.all([loadTemplates(), loadChannels(), loadVariables()]);
+});
+</script>
+
+<style scoped lang="scss">
+.notify-config-container {
+	padding: 8px;
+	.tpl-toolbar {
+		margin-bottom: 8px;
+	}
+	.cfg-tip {
+		.hint {
+			color: #909399;
+			font-size: 12px;
+			margin-left: 10px;
+		}
+	}
+}
+</style>

+ 24 - 24
Web/src/views/approvalFlow/statistics/index.vue

@@ -1,5 +1,5 @@
 <template>
-	<div class="flow-statistics-container">
+	<div class="flow-statistics-container flow-statistics">
 		<!-- 筛选区 -->
 		<el-card shadow="never" class="filter-card">
 			<el-form :model="filter" inline label-width="90px" size="default">
@@ -99,19 +99,19 @@
 				<el-tab-pane label="按流程" name="flow">
 					<el-table :data="flowRows" v-loading="loadingByFlow" border stripe style="width: 100%" size="default">
 						<el-table-column prop="flowName" label="流程名称" min-width="160" show-overflow-tooltip />
-						<el-table-column prop="flowCode" label="编码" width="140" show-overflow-tooltip />
-						<el-table-column label="业务类型" width="120" show-overflow-tooltip>
+						<el-table-column prop="flowCode" label="编码" width="140" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column label="业务类型" width="120" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide">
 							<template #default="{ row }">{{ row.bizTypeName || row.bizType || '-' }}</template>
 						</el-table-column>
 						<el-table-column prop="totalCount" label="发起数" width="80" align="right" sortable />
 						<el-table-column prop="completedCount" label="已完成" width="80" align="right" sortable />
-						<el-table-column prop="runningCount" label="进行中" width="80" align="right" sortable />
-						<el-table-column prop="rejectedCount" label="已拒绝" width="80" align="right" sortable />
+						<el-table-column prop="runningCount" label="进行中" width="80" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="rejectedCount" label="已拒绝" width="80" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
 						<el-table-column label="通过率" width="100" align="right" sortable :sort-by="(r: any) => r.passRate">
 							<template #default="{ row }">{{ formatPct(row.passRate) }}</template>
 						</el-table-column>
-						<el-table-column prop="avgDurationHours" label="平均耗时(h)" width="110" align="right" sortable />
-						<el-table-column prop="maxDurationHours" label="最长耗时(h)" width="110" align="right" sortable />
+						<el-table-column prop="avgDurationHours" label="平均耗时(h)" width="110" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="maxDurationHours" label="最长耗时(h)" width="110" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
 					</el-table>
 				</el-tab-pane>
 
@@ -125,25 +125,25 @@
 					<div ref="topNodeChartEl" class="chart-box" style="height: 280px; margin-bottom: 12px" />
 
 					<el-table :data="nodeRows" v-loading="loadingByNode" border stripe style="width: 100%" size="default">
-						<el-table-column prop="flowName" label="流程" width="160" show-overflow-tooltip />
+						<el-table-column prop="flowName" label="流程" width="160" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
 						<el-table-column prop="nodeName" label="节点" min-width="140" show-overflow-tooltip />
-						<el-table-column label="类型" width="110">
+						<el-table-column label="类型" width="110" class-name="mobile-hide" label-class-name="mobile-hide">
 							<template #default="{ row }">
 								<el-tag size="small" :type="nodeTypeTagType(row.nodeType)">{{ nodeTypeLabel(row.nodeType) }}</el-tag>
 							</template>
 						</el-table-column>
 						<el-table-column prop="flowCount" label="流经次数" width="90" align="right" sortable />
 						<el-table-column prop="avgDurationHours" label="平均停留(h)" width="110" align="right" sortable />
-						<el-table-column prop="maxDurationHours" label="最长停留(h)" width="110" align="right" sortable />
-						<el-table-column prop="approveCount" label="同意" width="70" align="right" sortable />
-						<el-table-column prop="rejectCount" label="拒绝" width="70" align="right" sortable />
-						<el-table-column label="拒绝率" width="90" align="right" sortable :sort-by="(r: any) => r.rejectRate">
+						<el-table-column prop="maxDurationHours" label="最长停留(h)" width="110" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="approveCount" label="同意" width="70" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="rejectCount" label="拒绝" width="70" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column label="拒绝率" width="90" align="right" sortable :sort-by="(r: any) => r.rejectRate" class-name="mobile-hide" label-class-name="mobile-hide">
 							<template #default="{ row }">{{ formatPct(row.rejectRate) }}</template>
 						</el-table-column>
-						<el-table-column prop="timeoutCount" label="超时数" width="80" align="right" sortable />
-						<el-table-column prop="pendingCount" label="待办中" width="80" align="right" sortable />
-						<el-table-column prop="longestApproverName" label="最长审批人" width="110" show-overflow-tooltip />
-						<el-table-column prop="shortestApproverName" label="最短审批人" width="110" show-overflow-tooltip />
+						<el-table-column prop="timeoutCount" label="超时数" width="80" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="pendingCount" label="待办中" width="80" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="longestApproverName" label="最长审批人" width="110" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="shortestApproverName" label="最短审批人" width="110" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
 					</el-table>
 				</el-tab-pane>
 
@@ -151,15 +151,15 @@
 				<el-tab-pane label="按审批人" name="approver">
 					<el-table :data="approverRows" v-loading="loadingByApprover" border stripe style="width: 100%" size="default">
 						<el-table-column prop="approverName" label="审批人" width="130" show-overflow-tooltip />
-						<el-table-column prop="orgName" label="部门" min-width="140" show-overflow-tooltip />
+						<el-table-column prop="orgName" label="部门" min-width="140" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
 						<el-table-column prop="totalAssigned" label="收到任务" width="90" align="right" sortable />
 						<el-table-column prop="completedCount" label="已完成" width="80" align="right" sortable />
-						<el-table-column prop="pendingCount" label="待办中" width="80" align="right" sortable />
-						<el-table-column prop="avgProcessHours" label="平均处理(h)" width="110" align="right" sortable />
-						<el-table-column prop="timeoutCount" label="超时处理" width="90" align="right" sortable />
-						<el-table-column prop="approveCount" label="同意" width="70" align="right" sortable />
-						<el-table-column prop="rejectCount" label="拒绝" width="70" align="right" sortable />
-						<el-table-column prop="transferCount" label="转办" width="70" align="right" sortable />
+						<el-table-column prop="pendingCount" label="待办中" width="80" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="avgProcessHours" label="平均处理(h)" width="110" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="timeoutCount" label="超时处理" width="90" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="approveCount" label="同意" width="70" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="rejectCount" label="拒绝" width="70" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
+						<el-table-column prop="transferCount" label="转办" width="70" align="right" sortable class-name="mobile-hide" label-class-name="mobile-hide" />
 					</el-table>
 				</el-tab-pane>
 			</el-tabs>

+ 65 - 0
ai-dop-platform/tools/_apply_notify_tpl_cfg.py

@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+"""P4-16 扩展:模板 / 渠道覆盖层 DDL + 通知配置菜单落库。
+
+对应决策:
+- 共享开发库 EnableInitDb/EnableInitTable 均为 false,需手动执行 DDL。
+- FlowNotifyTemplateService.EnsureSystemTemplates() 会在后端启动时幂等写入 9 条默认模板,
+  故本脚本仅负责建表;模板预置由后端自动完成。
+- 通知配置菜单(Id=1310300010106)需要手动 upsert。
+"""
+import pymysql
+from pathlib import Path
+
+DDL = Path(r"d:/Projects/Ai-DOP/SourceCode/ZZYDOP/doc/migrations/2026-04-16_approval_flow_notify_tpl_cfg.sql")
+
+raw = DDL.read_text(encoding='utf-8')
+lines = [ln for ln in raw.splitlines() if not ln.strip().startswith('--')]
+clean = '\n'.join(lines)
+statements = [s.strip() for s in clean.split(';') if s.strip()]
+print(f"parsed {len(statements)} DDL statements")
+
+conn = pymysql.connect(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+try:
+    with conn.cursor() as cur:
+        for st in statements:
+            print(f"--> {st[:80]}...")
+            cur.execute(st)
+            print("    done")
+
+        for t in ("ApprovalFlowNotifyTemplate", "ApprovalFlowNotifyConfig"):
+            cur.execute(f"SHOW TABLES LIKE '{t}'")
+            print(f"{t}: {cur.fetchall()}")
+
+        # 通知配置菜单 upsert
+        cur.execute("SELECT Id, Title FROM SysMenu WHERE Id = 1310300010106")
+        existed = cur.fetchone()
+        if existed:
+            print(f"menu exists: {existed}")
+        else:
+            cur.execute("""
+                INSERT INTO SysMenu (Id, Pid, Title, Path, Name, Component, Icon, Type, OrderNo, IsEnabled, ShowInTab, IsHidden, IsFrameShow, IsLinkOut, CreateTime, UpdateTime, IsDelete)
+                VALUES (1310300010106, 1310300010100, '通知配置',
+                        '/aidop/flowManage/flowNotifyConfig', 'flowNotifyConfig',
+                        '/approvalFlow/notifyConfig/index', 'ele-Bell',
+                        2, 150, 1, 1, 0, 1, 0, NOW(), NOW(), 0)
+            """)
+            print("menu inserted: 通知配置")
+
+        # 将菜单授权给超级管理员默认角色(1300000000101)
+        cur.execute("SELECT COUNT(*) FROM SysRoleMenu WHERE RoleId = 1300000000101 AND MenuId = 1310300010106")
+        if cur.fetchone()[0] == 0:
+            cur.execute("INSERT INTO SysRoleMenu (Id, RoleId, MenuId, IsDelete) VALUES (UUID_SHORT(), 1300000000101, 1310300010106, 0)")
+            print("SysRoleMenu grant inserted: 超级管理员 -> 通知配置")
+
+        # 授权给默认租户
+        cur.execute("SELECT COUNT(*) FROM SysTenantMenu WHERE TenantId = 1300000000100 AND MenuId = 1310300010106")
+        if cur.fetchone()[0] == 0:
+            cur.execute("INSERT INTO SysTenantMenu (Id, TenantId, MenuId, IsDelete) VALUES (UUID_SHORT(), 1300000000100, 1310300010106, 0)")
+            print("SysTenantMenu grant inserted: 默认租户 -> 通知配置")
+finally:
+    conn.close()
+print("all done")

+ 97 - 0
ai-dop-platform/tools/_insert_notify_config_menu.py

@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+"""Upsert 通知配置菜单 (Id=1310300010106) 到共享库。
+
+做法:以 审批统计(Id=1310300010105) 记录为模板深拷贝,覆盖 Id / Title / Path / Name / Component / Icon / OrderNo。
+然后将所有已授权给 105 的 Role / Tenant 复制到 106。
+"""
+import pymysql
+
+conn = pymysql.connect(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+try:
+    with conn.cursor(pymysql.cursors.DictCursor) as cur:
+        cur.execute("SELECT 1 FROM SysMenu WHERE Id = 1310300010106")
+        if cur.fetchone():
+            print("menu exists, skip insert")
+        else:
+            cur.execute("SELECT * FROM SysMenu WHERE Id = 1310300010105")
+            src = cur.fetchone()
+            if not src:
+                print("!! 源菜单 1310300010105 不存在,中止。先执行菜单种子修复脚本。")
+                raise SystemExit(1)
+
+            new_row = dict(src)
+            new_row['Id'] = 1310300010106
+            new_row['Title'] = '通知配置'
+            new_row['Path'] = '/aidop/flowManage/flowNotifyConfig'
+            new_row['Name'] = 'flowNotifyConfig'
+            new_row['Component'] = '/approvalFlow/notifyConfig/index'
+            new_row['Icon'] = 'ele-Bell'
+            new_row['OrderNo'] = 150
+            new_row['Remark'] = '审批流通知模板与渠道配置管理'
+
+            cols = list(new_row.keys())
+            placeholders = ", ".join(["%s"] * len(cols))
+            col_list = ", ".join(f"`{c}`" for c in cols)
+            sql = f"INSERT INTO SysMenu ({col_list}) VALUES ({placeholders})"
+            cur.execute(sql, list(new_row.values()))
+            print(f"menu inserted: 通知配置 Id=1310300010106")
+
+        # Role 授权复制
+        cur.execute("SELECT DISTINCT RoleId FROM SysRoleMenu WHERE MenuId = 1310300010105")
+        role_ids = [r['RoleId'] for r in cur.fetchall()]
+        print(f"roles with 审批统计 access: {role_ids}")
+        for rid in role_ids:
+            cur.execute("SELECT 1 FROM SysRoleMenu WHERE RoleId=%s AND MenuId=1310300010106", (rid,))
+            if cur.fetchone():
+                continue
+            cur.execute("SELECT * FROM SysRoleMenu WHERE RoleId=%s AND MenuId=1310300010105 LIMIT 1", (rid,))
+            src = cur.fetchone()
+            if not src:
+                continue
+            new_row = dict(src)
+            new_row['Id'] = None
+            new_row['MenuId'] = 1310300010106
+            # 使用 UUID_SHORT
+            cols = [c for c in new_row.keys() if c != 'Id']
+            placeholders = ", ".join(["%s"] * len(cols))
+            col_list = ", ".join(f"`{c}`" for c in cols) + ", `Id`"
+            sql = f"INSERT INTO SysRoleMenu ({col_list}) VALUES ({placeholders}, UUID_SHORT())"
+            cur.execute(sql, [new_row[c] for c in cols])
+            print(f"  granted RoleMenu: Role={rid} -> Menu=106")
+
+        # Tenant 授权复制
+        cur.execute("SELECT DISTINCT TenantId FROM SysTenantMenu WHERE MenuId = 1310300010105")
+        t_ids = [r['TenantId'] for r in cur.fetchall()]
+        print(f"tenants with 审批统计 access: {t_ids}")
+        for tid in t_ids:
+            cur.execute("SELECT 1 FROM SysTenantMenu WHERE TenantId=%s AND MenuId=1310300010106", (tid,))
+            if cur.fetchone():
+                continue
+            cur.execute("SELECT * FROM SysTenantMenu WHERE TenantId=%s AND MenuId=1310300010105 LIMIT 1", (tid,))
+            src = cur.fetchone()
+            if not src:
+                continue
+            new_row = dict(src)
+            new_row['Id'] = None
+            new_row['MenuId'] = 1310300010106
+            cols = [c for c in new_row.keys() if c != 'Id']
+            placeholders = ", ".join(["%s"] * len(cols))
+            col_list = ", ".join(f"`{c}`" for c in cols) + ", `Id`"
+            sql = f"INSERT INTO SysTenantMenu ({col_list}) VALUES ({placeholders}, UUID_SHORT())"
+            cur.execute(sql, [new_row[c] for c in cols])
+            print(f"  granted TenantMenu: Tenant={tid} -> Menu=106")
+
+        print("verify:")
+        cur.execute("SELECT Id, Pid, Title, Path, Component FROM SysMenu WHERE Id=1310300010106")
+        print(cur.fetchone())
+        cur.execute("SELECT COUNT(*) AS n FROM SysRoleMenu WHERE MenuId=1310300010106")
+        print("RoleMenu count:", cur.fetchone())
+        cur.execute("SELECT COUNT(*) AS n FROM SysTenantMenu WHERE MenuId=1310300010106")
+        print("TenantMenu count:", cur.fetchone())
+finally:
+    conn.close()
+print("done")

+ 109 - 0
ai-dop-platform/tools/_verify_notify_tpl_cfg.py

@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+"""P4-16 扩展:通知模板 / 通知渠道配置 冒烟验证(DB 侧)。
+
+仅做结构与种子数据可用性检查,不依赖前后端 HTTP 联调:
+- T1:ApprovalFlowNotifyTemplate / ApprovalFlowNotifyConfig 两表存在,列齐。
+- T2:后端启动过至少一次后,系统预置模板应至少 9 条(NewTask / Urge / FlowCompleted / Transferred / Returned / AddSign / Withdrawn / Escalated / Timeout 的全局默认)。
+- T3:通知配置菜单 SysMenu Id=1310300010106 存在且已授权(RoleMenu + TenantMenu 有记录)。
+- T4:渲染兜底:模拟 BizType 级覆盖缺失,应能退回全局模板(通过 Python 端模拟 SELECT)。
+
+使用方法:
+    python _verify_notify_tpl_cfg.py
+若 T2 失败(模板 0 条),说明后端尚未启动过(InitTables + EnsureSystemTemplates 未执行)。
+"""
+from __future__ import annotations
+import pymysql
+import sys
+
+CONN_ARGS = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+EXPECTED_TYPES = {"NewTask", "Urge", "FlowCompleted", "Transferred", "Returned", "AddSign", "Withdrawn", "Escalated", "Timeout"}
+
+
+def ok(name: str, cond: bool, detail: str = "") -> bool:
+    flag = "PASS" if cond else "FAIL"
+    print(f"[{flag}] {name}" + (f" - {detail}" if detail else ""))
+    return cond
+
+
+def main() -> int:
+    conn = pymysql.connect(**CONN_ARGS)
+    passed = 0
+    total = 0
+    try:
+        with conn.cursor(pymysql.cursors.DictCursor) as cur:
+            # T1 表结构
+            for t, expected_cols in [
+                ("ApprovalFlowNotifyTemplate", {"NotifyType", "BizType", "Title", "Content", "IsEnabled", "IsSystem"}),
+                ("ApprovalFlowNotifyConfig", {"ChannelKey", "Enabled", "WebhookUrl", "Secret", "TemplateId", "Remark"}),
+            ]:
+                total += 1
+                cur.execute(f"SHOW TABLES LIKE '{t}'")
+                has_tbl = bool(cur.fetchall())
+                if not has_tbl:
+                    ok(f"T1 表 {t} 存在", False, "表不存在")
+                    continue
+                cur.execute(f"DESC `{t}`")
+                cols = {r["Field"] for r in cur.fetchall()}
+                miss = expected_cols - cols
+                if ok(f"T1 表 {t} 结构", not miss, f"缺字段: {miss}" if miss else f"列 {len(cols)} 个"):
+                    passed += 1
+
+            # T2 默认模板种子
+            total += 1
+            cur.execute(
+                "SELECT NotifyType, COUNT(*) AS n FROM ApprovalFlowNotifyTemplate "
+                "WHERE BizType='' AND IsSystem=1 GROUP BY NotifyType"
+            )
+            rows = cur.fetchall()
+            seed_types = {r["NotifyType"] for r in rows}
+            miss = EXPECTED_TYPES - seed_types
+            if ok(
+                "T2 系统预置模板(全局)覆盖 9 类通知",
+                not miss,
+                f"缺: {miss}" if miss else f"已预置 {len(seed_types)} 类({len(rows)} 行)",
+            ):
+                passed += 1
+
+            # T3 通知配置菜单
+            total += 1
+            cur.execute("SELECT Id, Title, Path FROM SysMenu WHERE Id=1310300010106")
+            m = cur.fetchone()
+            cur.execute("SELECT COUNT(*) AS n FROM SysRoleMenu WHERE MenuId=1310300010106")
+            r = cur.fetchone()["n"]
+            cur.execute("SELECT COUNT(*) AS n FROM SysTenantMenu WHERE MenuId=1310300010106")
+            t_cnt = cur.fetchone()["n"]
+            cond = bool(m) and r > 0 and t_cnt > 0
+            detail = f"menu={bool(m)} RoleMenu={r} TenantMenu={t_cnt}"
+            if ok("T3 通知配置菜单已挂载 + 授权", cond, detail):
+                passed += 1
+
+            # T4 渲染兜底(BizType 级不存在时回退全局)
+            total += 1
+            biz = "__VERIFY_NOT_EXIST_BIZ__"
+            cur.execute(
+                "SELECT Id FROM ApprovalFlowNotifyTemplate WHERE NotifyType='NewTask' AND BizType=%s",
+                (biz,),
+            )
+            biz_hit = cur.fetchone()
+            cur.execute(
+                "SELECT Id FROM ApprovalFlowNotifyTemplate WHERE NotifyType='NewTask' AND BizType='' AND IsEnabled=1 LIMIT 1"
+            )
+            global_hit = cur.fetchone()
+            fallback_ok = biz_hit is None and global_hit is not None
+            if ok("T4 BizType 级缺失时可回退全局", fallback_ok):
+                passed += 1
+
+        print("\n==============================")
+        print(f"结果:{passed}/{total} 通过")
+        return 0 if passed == total else 1
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 49 - 0
doc/migrations/2026-04-16_approval_flow_notify_tpl_cfg.sql

@@ -0,0 +1,49 @@
+-- =====================================================
+-- P4-16 延伸:审批流通知模板 + 通知渠道配置覆盖层 DDL
+-- 适用数据库:MySQL 8.0+
+-- 运行方式:共享开发库 EnableInitDb / EnableInitTable 为 false,
+--           需由 DBA 手动执行本脚本后重启后端服务;
+--           启动时 FlowNotifyTemplateService.EnsureSystemTemplates 会幂等写入 9 条默认模板。
+-- 创建时间:2026-04-16
+-- =====================================================
+
+-- 通知模板表(NotifyType + BizType 唯一;BizType='' = 全局默认,非空 = 业务覆盖)
+CREATE TABLE IF NOT EXISTS `ApprovalFlowNotifyTemplate` (
+    `Id`              BIGINT         NOT NULL COMMENT '主键Id',
+    `NotifyType`      VARCHAR(32)    NOT NULL COMMENT '通知类型',
+    `BizType`         VARCHAR(32)    NOT NULL DEFAULT '' COMMENT '业务类型(空=全局默认)',
+    `Title`           VARCHAR(256)   NOT NULL COMMENT '标题模板(支持 {变量})',
+    `Content`         VARCHAR(1024)  NOT NULL COMMENT '正文模板(支持 {变量})',
+    `IsEnabled`       TINYINT(1)     NOT NULL DEFAULT 1 COMMENT '是否启用',
+    `IsSystem`        TINYINT(1)     NOT NULL DEFAULT 0 COMMENT '是否系统预置',
+    `Remark`          VARCHAR(256)   NULL     COMMENT '备注',
+    `CreateTime`      DATETIME       NULL     COMMENT '创建时间',
+    `UpdateTime`      DATETIME       NULL     COMMENT '更新时间',
+    `CreateUserId`    BIGINT         NULL     COMMENT '创建者Id',
+    `CreateUserName`  VARCHAR(64)    NULL     COMMENT '创建者',
+    `UpdateUserId`    BIGINT         NULL     COMMENT '修改者Id',
+    `UpdateUserName`  VARCHAR(64)    NULL     COMMENT '修改者',
+    `IsDelete`        TINYINT(1)     NOT NULL DEFAULT 0 COMMENT '是否删除',
+    PRIMARY KEY (`Id`),
+    UNIQUE KEY `idx_notifytpl_type_biz` (`NotifyType`, `BizType`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流通知模板';
+
+-- 通知渠道配置覆盖层(每渠道一条;DB 有则覆盖 ApprovalFlow.json 的值)
+CREATE TABLE IF NOT EXISTS `ApprovalFlowNotifyConfig` (
+    `Id`              BIGINT         NOT NULL COMMENT '主键Id',
+    `ChannelKey`      VARCHAR(16)    NOT NULL COMMENT '渠道标识 SignalR/Email/Sms/DingTalk/WorkWeixin',
+    `Enabled`         TINYINT(1)     NOT NULL DEFAULT 0 COMMENT '是否启用',
+    `WebhookUrl`      VARCHAR(512)   NULL     COMMENT 'Webhook URL',
+    `Secret`          VARCHAR(128)   NULL     COMMENT '加签 Secret',
+    `TemplateId`      VARCHAR(64)    NULL     COMMENT '短信模板Id',
+    `Remark`          VARCHAR(256)   NULL     COMMENT '备注',
+    `CreateTime`      DATETIME       NULL     COMMENT '创建时间',
+    `UpdateTime`      DATETIME       NULL     COMMENT '更新时间',
+    `CreateUserId`    BIGINT         NULL     COMMENT '创建者Id',
+    `CreateUserName`  VARCHAR(64)    NULL     COMMENT '创建者',
+    `UpdateUserId`    BIGINT         NULL     COMMENT '修改者Id',
+    `UpdateUserName`  VARCHAR(64)    NULL     COMMENT '修改者',
+    `IsDelete`        TINYINT(1)     NOT NULL DEFAULT 0 COMMENT '是否删除',
+    PRIMARY KEY (`Id`),
+    UNIQUE KEY `idx_notifycfg_channel` (`ChannelKey`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流通知渠道配置';

+ 28 - 3
doc/plan/审批流-综合优化方案.md

@@ -289,12 +289,36 @@
 - DB 变更需手动:`doc/migrations/2026-04-16_approval_flow_notify_log.sql`(共享开发库 `EnableInitSeed=false`)
 - 验证:`_verify_p4.py` 15/15 PASS(NotifyLog 正确写入,未启用渠道不产生日志)
 
+### 调度语义(已拍板,不再变更)
+
+- **串行调度**:`FlowNotifyService.NotifyUsers` 对已启用的 pusher 顺序 `await`(SignalR → Email → Sms → DingTalk → WorkWeixin),单条通道异常只落 `ApprovalFlowNotifyLog.Success=false`,不影响其它通道,也不阻塞审批主流程
+- **无总开关**:粒度停在单渠道 `ApprovalFlow:Notify:<Channel>` bool。想一键静默全部外部推送,把 4 个外部渠道置 `false` 即可(默认就是这样)
+- 若未来运维反馈"某条外部通道偶发慢拖累整串",再评估切 `Task.WhenAll` 并行
+
 ### 后续(P5 或更晚)
 
 - `SysUser` 补 `DingId` / `WeixinId` 字段 → 钉钉/企微应用消息精准 at 人
-- 通知模板化(替换当前硬编码的标题/内容)
 - 失败重试(当前失败即落日志,不重试)
-- 前端"通知渠道配置"管理页
+
+### 延伸落地(2026-04-17,第七批)
+
+- **通知标题/正文模板化** ✅ — 新增 `ApprovalFlowNotifyTemplate` 表 + `FlowNotifyTemplateService`
+  - 全局默认(BizType=空)+ `BizType` 级覆盖两级;后者缺失时自动回退全局
+  - 变量插值:`{title}` / `{nodeName}` / `{initiatorName}` / `{statusLabel}` 等
+  - 启动时 `EnsureSystemTemplates()` 幂等播种 9 类(`NewTask` / `Urge` / `FlowCompleted` / `Transferred` / `Returned` / `AddSign` / `Withdrawn` / `Escalated` / `Timeout`)
+  - `FlowNotifyService.Notify*` 全部贯穿 `bizType` 参数;`FlowEngineService` 调用点同步补传 `instance.BizType`
+  - 管理员可在前端对任意 `BizType × NotifyType` 组合新增覆盖或编辑;系统预置(`IsSystem=1`)可改不可删
+- **通知渠道配置管理页** ✅ — 新增 `ApprovalFlowNotifyConfig` 表 + `FlowNotifyConfigService`
+  - **DB 覆盖层 + JSON 兜底**(零破坏升级):`GetEffectiveAsync(channelKey)` 先查 DB,无记录走 `ApprovalFlow.json`
+  - 管理页按渠道行编辑 `Enabled` / `WebhookUrl` / `Secret`(仅钉钉) / `TemplateId`(仅短信) / `Remark`
+  - 表格显示 `Source=DB|JSON`,便于运维辨识来源
+  - 测试发送:仅覆盖 SignalR 链路验证(向当前登录用户推一条站内测试消息);外部渠道真实发起由 `TestSend` 后续扩展时再加
+  - 新菜单 `通知配置` (Id=`1310300010106`) 种子 + 手动 upsert 脚本 `ai-dop-platform/tools/_insert_notify_config_menu.py`
+- **移动端适配 P2-12 核心三页** ✅ — 覆盖审批中心 / ApprovalPanel / 统计看板(顺带通知配置页)
+  - 新增 `Web/src/theme/media/approval.scss`,走 `$xs`/`$sm` 两档断点,仅通过容器类作用域不污染全局
+  - 表格按 `mobile-hide` 类名隐藏次要列(业务单号 / 发起人 / 接收时间 等),保留标题 + 关键操作
+  - 统计看板 4 卡片在 `≤576px` 改 2 列,`.chart-box` 高度收紧到 200px
+  - ApprovalPanel 按钮改 50%/50% 网格、`textarea + 常用意见` 改竖排
 
 ---
 
@@ -426,7 +450,8 @@ public interface IFlowBizHandler
 | **第四批** ✅ | P1-5 并行网关(方案 B:`ApprovalFlowCompletedNode` 子表) | 高 | 8h |
 | **第五批** ✅ | **P3-15 节点级统计** ✅、**流程升级机制收尾**(AutoEscalate / 手动升级)✅ | 中 | 12–16h |
 | **第六批** ✅(2026-04-16) | **P4-17 Handler 回调增补 `instanceId` / `lastApproverId`** ✅、**P4-16 外部推送渠道补齐**(方案 A:Email/SMS 落地 + DingTalk/WorkWeixin Webhook 首版)✅ | 中 | 8h |
-| *后期待办(需人工确认再做)* | *P2-12 移动端适配*、*P3-13 流程模拟器*(设计器空跑验证)、*P3-14 子流程 callActivity*、*P4-16 延伸*(应用消息精准 at 人、通知模板化、失败重试、前端通知渠道管理页) | *中 / 高 / 中 / 中* | *另行安排* |
+| **第七批** ✅(2026-04-17) | **P2-12 核心三页移动端适配**(审批中心 / ApprovalPanel / 统计看板,附带通知配置页)✅、**P4-16 延伸**:通知标题/正文模板化(全局默认 + `BizType` 覆盖)✅、**前端通知渠道配置管理页**(模板 Tab + 渠道 Tab,DB 覆盖层 + JSON 兜底)✅ | 中 | 8h |
+| *后期待办(需人工确认再做)* | *P3-13 流程模拟器*(设计器空跑验证)、*P3-14 子流程 callActivity*、*P2-12 扩展*(编辑器 / 代理 / 业务类型 / 流程管理等非核心页移动端适配)、*P4-16 深化*(应用消息精准 at 人、失败重试、渠道级灰度发布) | *高 / 高 / 中 / 中* | *另行安排* |
 
 ---
 

+ 23 - 4
doc/审批流功能说明.md

@@ -1,7 +1,7 @@
 # 审批流功能说明
 
-> 更新日期:2026-04-17(v2.4.81 / 后端 1.0.48
-> 本轮新增:部门负责人审批人类型、待办角标、审批代理、批量审批、版本管理、并行网关、流程图预览、审批意见模板、节点超时自动处理(4 种动作)、手动升级、审批统计、审批中心代理标签。
+> 更新日期:2026-04-17(v2.4.84 / 后端 1.0.51
+> 本轮新增:部门负责人审批人类型、待办角标、审批代理、批量审批、版本管理、并行网关、流程图预览、审批意见模板、节点超时自动处理(4 种动作)、手动升级、审批统计、审批中心代理标签、**通知标题/正文模板化(全局默认 + BizType 覆盖)**、**通知渠道配置管理页(DB 覆盖层 + JSON 兜底)**、**核心三页移动端适配**
 
 ## 一、功能概述
 
@@ -24,7 +24,10 @@
 - **导航栏待办角标**:头部铃铛实时显示待办数量
 - **审批统计**:流程、节点、审批人三维度聚合 + 日趋势 / Top 慢节点图表
 - 业务类型管理:管理员自助注册新业务类型
-- 实时通知(SignalR 站内消息)
+- 实时通知(SignalR 站内消息)+ **邮件 / 短信 / 钉钉 / 企业微信** 多通道调度
+- **通知模板化**:全局默认 + `BizType` 级覆盖,支持变量插值 `{title}` / `{nodeName}` 等
+- **通知配置管理页**:运行时修改渠道启停与凭据,立即生效
+- **核心三页已做移动端适配**:审批中心 / ApprovalPanel / 统计看板
 - 完整审批时间线与操作日志
 
 ---
@@ -38,6 +41,9 @@
 | 业务类型 | 管理审批业务类型编码(如订单评审、合同评审) | 管理员 |
 | 审批流程 | 管理和设计各业务类型的审批流程 | 管理员 |
 | 审批中心 | 处理待办审批、查看已办和已发起的流程 | 所有用户 |
+| 审批代理 | 设置审批代理人、代理期间、业务类型范围 | 所有用户 |
+| 审批统计 | 按时间段 / 流程 / 业务类型 / 部门聚合审批数据 | 管理员 |
+| **通知配置** | 管理通知模板(含 `BizType` 级覆盖)与通知渠道(启停、凭据) | 管理员 |
 | 审批统计 | 流程 / 节点 / 审批人多维度聚合报表 | 管理员 |
 
 此外,**顶部导航栏**固定显示审批待办角标(铃铛图标),点击可快速跳转到"我的待办"。**个人中心**提供"审批代理"设置入口,用户可自助设置代理人。
@@ -446,7 +452,20 @@
 
 每次通知分发写一条 `ApprovalFlowNotifyLog`(渠道、目标用户、成功/失败、错误信息、耗时),便于审计和排障。未启用的渠道不产生日志;单通道失败不阻塞其它通道,也不阻塞审批主流程。
 
-应用消息精准 @人(需 `SysUser.DingId` / `WeixinId`)、通知模板化、失败重试将在 P5 批次继续推进。
+**通知标题/正文模板化(2026-04-17 P4-16 延伸已落地):**
+
+- 上表第二列的默认文案现在来自数据库表 `ApprovalFlowNotifyTemplate`,由后端启动时 `FlowNotifyTemplateService.EnsureSystemTemplates()` 幂等播种 9 条"全局默认"记录;
+- 支持两级:**全局默认**(`BizType=''`)+ **`BizType` 级覆盖**。渲染时先按 `(NotifyType, BizType)` 精确匹配,若无则回退到 `(NotifyType, '')` 全局模板;
+- 变量插值:`{title}` / `{nodeName}` / `{initiatorName}` / `{statusLabel}` / `{approverName}` / `{targetName}` / `{timeoutHours}` 等;完整变量字典见管理页"查看可用变量"弹窗或 `/api/flowNotifyTemplate/variables`;
+- 管理员可为任意业务类型编写专属文案(例:"S1 订单评审"收到 `NewTask` 显示"您有一条新的订单评审待处理");系统预置(`IsSystem=1`)可改不可删。
+
+**通知渠道配置运行时可改(2026-04-17 P4-16 延伸已落地):**
+
+- 新增 `ApprovalFlowNotifyConfig` 作为 DB 覆盖层;读取时采用 **DB 覆盖 + JSON 兜底** 策略:有 DB 记录就以 DB 为准,否则回退 `ApprovalFlow.json`;
+- 新增管理页 **`审批流 → 通知配置`**(Id `1310300010106`),含"通知模板"和"通知渠道"两个 Tab;
+- 渠道 Tab 可直接编辑 `Enabled` / `WebhookUrl` / `Secret` / `TemplateId`,保存即落库并立即生效,无需改 JSON 或重启;管理页还提供"发送 SignalR 测试通知"按钮快速验证站内消息链路。
+
+应用消息精准 @人(需 `SysUser.DingId` / `WeixinId`)、失败重试将在 P5 批次继续推进。
 
 ---
 

+ 62 - 1
doc/审批流集成开发指南.md

@@ -1,7 +1,8 @@
 # 审批流集成开发指南
 
-> 更新日期:2026-04-17(v2.4.81 / 后端 1.0.48
+> 更新日期:2026-04-17(v2.4.84 / 后端 1.0.51
 > 本轮新增:批量审批、升级、超时自动处理、审批代理、审批意见模板、流程版本、流程图预览、审批统计、导航栏待办角标等 API;`ApprovalPanel` 无需再外传 `currentUserId`;并行网关使用说明。
+> 2026-04-17 新增:**通知标题/正文模板化**(全局默认 + `BizType` 级覆盖)、**通知渠道配置管理页**(DB 覆盖层 + JSON 兜底,运行时即改即生效)、**核心三页移动端适配**(审批中心 / `ApprovalPanel` / 统计看板 / 通知配置页)。详见 11.1.1 / 11.1.2 / 附录。
 
 本文档说明如何将已有的审批流插件(`Admin.NET.Plugin.ApprovalFlow`)集成到业务模块中。以**订单评审(ORDER_REVIEW)**为实例,适用于所有需要接入审批流的业务场景。
 
@@ -262,6 +263,8 @@ import ApprovalPanel from '/@/views/approvalFlow/component/ApprovalPanel.vue';
 2. **新建审批流程**:进入 **Ai-DOP → 流程管理 → 审批流程**,点击"新增",选择业务类型为 `ORDER_REVIEW`
 3. **设计流程**:在流程列表点击"流程"进入设计器,拖拽节点配置审批人、条件分支等
 4. **发布流程**:设计完成后点击"发布"按钮
+5. **(可选)为该业务类型定制通知文案**:进入 **Ai-DOP → 流程管理 → 通知配置 → 通知模板 Tab**,选中对应 `NotifyType` 和 `BizType=ORDER_REVIEW` 点击"新增覆盖",编写带变量的标题与正文
+6. **(可选)启用外部推送渠道**:**通知配置 → 通知渠道 Tab**,编辑需要的渠道 → 打开 `Enabled` → 填 `WebhookUrl` / `Secret` / `TemplateId` → 保存(立即生效,无需重启)
 
 发布后,用户在对应业务表单中即可看到"提交审批"按钮。
 
@@ -494,6 +497,35 @@ urgent == 1 && customLevel >= 3
 - 失败不阻塞审批主流程,只落 `ApprovalFlowNotifyLog`
 - 业务 Handler 无需关心通知发送,引擎自动按事件触发
 
+#### 11.1.1 通知模板化(2026-04-17 P4-16 延伸已落地)
+
+默认通知文案不再是 C# 硬编码,而是落库在 `ApprovalFlowNotifyTemplate`,按 **全局默认 + `BizType` 级覆盖** 两级优先匹配:
+
+```
+FlowNotifyTemplateService.RenderAsync(notifyType, bizType, variables):
+    1) 精确匹配 (notifyType, bizType) 且 IsEnabled=true → 用 BizType 级模板
+    2) 否则回退 (notifyType, '') 且 IsEnabled=true → 用全局模板
+    3) 都没有 → C# 兜底字符串(避免空通知)
+    4) 用 {key} 替换 variables 中的字段
+```
+
+**业务接入**:业务 Handler 无需任何改动,引擎会自动使用流程实例的 `BizType` 做模板查询。若需要为某业务定制文案:
+
+- 路径 A(推荐运行时):前端 `审批流 → 通知配置 → 通知模板 Tab → 新增覆盖`,选 `NotifyType` 和 `BizType`,保存即生效;
+- 路径 B(迁移式):在插件的 SeedData 里追加 `new ApprovalFlowNotifyTemplate { NotifyType=..., BizType="your_biz", ... }`,提交发版。
+
+**常用变量**:`{title}` / `{nodeName}` / `{initiatorName}` / `{approverName}` / `{targetName}` / `{statusLabel}` / `{timeoutHours}`。完整列表见管理页"查看可用变量"弹窗或 `GET /api/flowNotifyTemplate/variables`。
+
+#### 11.1.2 通知渠道运行时可改(2026-04-17 P4-16 延伸已落地)
+
+原先渠道启停 / Webhook / 凭据只能改 `ApprovalFlow.json` 且需重启;现提供:
+
+- **DB 覆盖层**:表 `ApprovalFlowNotifyConfig`,前端管理页保存后立即生效;
+- **JSON 兜底**:库中无覆盖记录时继续使用 `ApprovalFlow.json`;
+- `FlowNotifyService` 内部只调 `FlowNotifyConfigService.GetEffectiveAsync(channelKey)`,业务侧无感知。
+
+**对运维:** 部署阶段仍建议在 `ApprovalFlow.json` 写一套基线值;再由管理员在运行时通过管理页做增量调整。
+
 ### 11.2 `OnFlowCompleted` / `OnNodeCompleted` 拿不到"审批人 ID"——P4-17(2026-04-16)已解决
 
 **现在**:Handler 合约已带 `instanceId + lastApproverId / approverUserId`:
@@ -790,3 +822,32 @@ Cursor 会按 `_verify_escalation.py` 的结构(登录 + 插入流程定义 +
 > 让 AI 读的文档越少越精准,别让它泛读"已完结的历史方案"浪费上下文。
 
 ---
+
+## 附录 A:核心页面移动端适配 (P2-12 首批)
+
+> 2026-04-17 落地。本批 **仅覆盖** `审批中心 / ApprovalPanel / 统计看板 / 通知配置页`。其它页面(流程管理、审批代理、业务类型、设计器)移动端体验保持原样(PC 为主)。
+
+### 断点与原则
+
+- 统一复用 `Web/src/theme/media/index.scss` 的 `$xs = 576px` / `$sm = 768px` 变量;
+- 新增文件 `Web/src/theme/media/approval.scss` 汇总审批流相关媒体查询,由 `media.scss` 统一 `@use`;
+- **作用域**:所有规则挂在容器类下(`.approval-center` / `.approval-panel` / `.flow-statistics` / `.notify-config-container`),严禁写全局 body/html 级别选择器;
+- **次要列隐藏**:通过 Element-Plus `<el-table-column class-name="mobile-hide" label-class-name="mobile-hide" />` 标记,`≤576px` 时由 CSS 统一 `display: none`;
+- **按钮/筛选器**:在手机宽度下改为 100% 宽度、垂直排列,避免超出屏幕;
+- **图表/流程图**:在手机宽度下压缩高度(统计 `.chart-box`→200px,`ApprovalPanel` 流程图→240px)。
+
+### 业务侧扩展
+
+若你希望把业务表单页的审批区域(嵌入 `ApprovalPanel`)在手机上保持良好体验,**无需任何改动** — `ApprovalPanel` 已自带移动端适配。
+
+如需新页面也复用"mobile-hide 列"能力:只要表格写在 `.approval-center / .approval-panel / .flow-statistics / .notify-config-container` 这四个容器之一内,列上打 `class-name="mobile-hide" label-class-name="mobile-hide"` 即可;否则需要在自己的页面 scss 里加一条同名媒体查询。
+
+### 不在本批范围内的页面
+
+| 页面 | 当前状态 | 说明 |
+|---|---|---|
+| 流程管理(列表 + 设计器) | 仅 PC | LogicFlow 画布不适合手机操作,设计工作仍建议 PC |
+| 业务类型 / 审批代理 | 仅 PC | 管理员低频使用 |
+| S1 / S2 等业务表单 | 由业务自决 | 业务方评估是否做移动适配 |
+
+---

+ 52 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Entity/ApprovalFlowNotifyConfig.cs

@@ -0,0 +1,52 @@
+namespace Admin.NET.Plugin.ApprovalFlow;
+
+/// <summary>
+/// 审批流通知渠道配置覆盖层(P4-16 延伸)
+/// 一条记录 = 一个渠道的运行时覆盖。
+/// FlowNotifyService 读取时:DB 有则用 DB,无则回退 ApprovalFlow.json。零破坏升级。
+/// </summary>
+[SugarTable(null, "审批流通知渠道配置")]
+[SugarIndex("idx_notifycfg_channel", nameof(ChannelKey), OrderByType.Asc, true)]
+public class ApprovalFlowNotifyConfig : EntityBase
+{
+    /// <summary>
+    /// 渠道标识(SignalR / Email / Sms / DingTalk / WorkWeixin),与 INotifyPusher.Channel 对齐
+    /// </summary>
+    [SugarColumn(ColumnDescription = "渠道标识", Length = 16)]
+    [Required, MaxLength(16)]
+    public string ChannelKey { get; set; } = "";
+
+    /// <summary>
+    /// 是否启用
+    /// </summary>
+    [SugarColumn(ColumnDescription = "是否启用", DefaultValue = "0")]
+    public bool Enabled { get; set; }
+
+    /// <summary>
+    /// Webhook URL(钉钉 / 企业微信群机器人)
+    /// </summary>
+    [SugarColumn(ColumnDescription = "WebhookURL", Length = 512, IsNullable = true)]
+    [MaxLength(512)]
+    public string? WebhookUrl { get; set; }
+
+    /// <summary>
+    /// 加签 Secret(钉钉,可选)
+    /// </summary>
+    [SugarColumn(ColumnDescription = "加签Secret", Length = 128, IsNullable = true)]
+    [MaxLength(128)]
+    public string? Secret { get; set; }
+
+    /// <summary>
+    /// 短信运营商模板 Id
+    /// </summary>
+    [SugarColumn(ColumnDescription = "短信模板Id", Length = 64, IsNullable = true)]
+    [MaxLength(64)]
+    public string? TemplateId { get; set; }
+
+    /// <summary>
+    /// 备注
+    /// </summary>
+    [SugarColumn(ColumnDescription = "备注", Length = 256, IsNullable = true)]
+    [MaxLength(256)]
+    public string? Remark { get; set; }
+}

+ 59 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Entity/ApprovalFlowNotifyTemplate.cs

@@ -0,0 +1,59 @@
+namespace Admin.NET.Plugin.ApprovalFlow;
+
+/// <summary>
+/// 审批流通知模板(P4-16 延伸)
+/// 一条模板 = 一对 (NotifyType, BizType);BizType="" 表示全局默认,非空表示业务定制覆盖。
+/// Title / Content 支持变量插值:{title} {nodeName} {initiatorName} {fromName} {statusText} 等。
+/// 未匹配到启用模板时,FlowNotifyService 回退到代码内置默认文案。
+/// </summary>
+[SugarTable(null, "审批流通知模板")]
+[SugarIndex("idx_notifytpl_type_biz", nameof(NotifyType), OrderByType.Asc, nameof(BizType), OrderByType.Asc, true)]
+public class ApprovalFlowNotifyTemplate : EntityBase
+{
+    /// <summary>
+    /// 通知类型(NewTask / Urge / FlowCompleted / Transferred / Returned / AddSign / Withdrawn / Escalated / Timeout)
+    /// </summary>
+    [SugarColumn(ColumnDescription = "通知类型", Length = 32)]
+    [Required, MaxLength(32)]
+    public string NotifyType { get; set; } = "";
+
+    /// <summary>
+    /// 业务类型编码;空字符串 = 全局默认,非空 = 按业务覆盖
+    /// </summary>
+    [SugarColumn(ColumnDescription = "业务类型", Length = 32, DefaultValue = "")]
+    [MaxLength(32)]
+    public string BizType { get; set; } = "";
+
+    /// <summary>
+    /// 标题模板(支持 {变量})
+    /// </summary>
+    [SugarColumn(ColumnDescription = "标题模板", Length = 256)]
+    [Required, MaxLength(256)]
+    public string Title { get; set; } = "";
+
+    /// <summary>
+    /// 正文模板(支持 {变量})
+    /// </summary>
+    [SugarColumn(ColumnDescription = "正文模板", Length = 1024)]
+    [Required, MaxLength(1024)]
+    public string Content { get; set; } = "";
+
+    /// <summary>
+    /// 是否启用
+    /// </summary>
+    [SugarColumn(ColumnDescription = "是否启用", DefaultValue = "1")]
+    public bool IsEnabled { get; set; } = true;
+
+    /// <summary>
+    /// 是否系统预置(预置模板不可删除,但可编辑覆盖)
+    /// </summary>
+    [SugarColumn(ColumnDescription = "是否系统预置", DefaultValue = "0")]
+    public bool IsSystem { get; set; }
+
+    /// <summary>
+    /// 备注
+    /// </summary>
+    [SugarColumn(ColumnDescription = "备注", Length = 256, IsNullable = true)]
+    [MaxLength(256)]
+    public string? Remark { get; set; }
+}

+ 1 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/SeedData/SysMenuSeedData.cs

@@ -25,6 +25,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             new SysMenu{ Id=1310300010102, Pid=1310300010100, Title="审批中心", Path="/aidop/flowManage/approvalFlowCenter", Name="approvalFlowCenter", Component="/approvalFlow/center/index", Icon="ele-Checked", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2026-04-15 00:00:00"), OrderNo=120 },
             new SysMenu{ Id=1310300010104, Pid=1310300010100, Title="审批代理", Path="/aidop/flowManage/flowDelegate", Name="flowDelegate", Component="/approvalFlow/delegate/index", Icon="ele-Switch", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2026-04-16 00:00:00"), OrderNo=130 },
             new SysMenu{ Id=1310300010105, Pid=1310300010100, Title="审批统计", Path="/aidop/flowManage/flowStatistics", Name="flowStatistics", Component="/approvalFlow/statistics/index", Icon="ele-DataAnalysis", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2026-04-17 00:00:00"), OrderNo=140 },
+            new SysMenu{ Id=1310300010106, Pid=1310300010100, Title="通知配置", Path="/aidop/flowManage/flowNotifyConfig", Name="flowNotifyConfig", Component="/approvalFlow/notifyConfig/index", Icon="ele-Bell", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2026-04-18 00:00:00"), OrderNo=150 },
         };
     }
 }

+ 12 - 12
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs

@@ -174,7 +174,7 @@ public class FlowEngineService : ITransient
         // P4-17: 驳回人 = 当前被指派的审批人
         await InvokeHandler(instance.BizType,
             h => h.OnFlowCompleted(instance.BizId, instance.Id, FlowInstanceStatusEnum.Rejected, task.AssigneeId));
-        await _notifyService.NotifyFlowCompleted(instance.InitiatorId, instance.Id, instance.Title, FlowInstanceStatusEnum.Rejected);
+        await _notifyService.NotifyFlowCompleted(instance.InitiatorId, instance.Id, instance.Title, FlowInstanceStatusEnum.Rejected, instance.BizType);
     }
 
     // ═══════════════════════════════════════════
@@ -211,7 +211,7 @@ public class FlowEngineService : ITransient
             $"{comment} → 转办给 {targetUser?.RealName}");
 
         var instance = await _instanceRep.GetByIdAsync(task.InstanceId);
-        await _notifyService.NotifyTransferred(targetUserId, task.InstanceId, instance?.Title ?? "", _userManager.RealName);
+        await _notifyService.NotifyTransferred(targetUserId, task.InstanceId, instance?.Title ?? "", _userManager.RealName, instance?.BizType ?? "");
     }
 
     /// <summary>
@@ -255,7 +255,7 @@ public class FlowEngineService : ITransient
         await InvokeHandler(instance.BizType,
             h => h.OnFlowCompleted(instance.BizId, instanceId, FlowInstanceStatusEnum.Cancelled, null));
 
-        await _notifyService.NotifyWithdrawn(cancelledUserIds, instanceId, instance.Title, instance.InitiatorName);
+        await _notifyService.NotifyWithdrawn(cancelledUserIds, instanceId, instance.Title, instance.InitiatorName, instance.BizType);
     }
 
     /// <summary>
@@ -289,7 +289,7 @@ public class FlowEngineService : ITransient
             .Where(t => t.InstanceId == instance.Id && t.NodeId == prevNodeId && t.Status == FlowTaskStatusEnum.Pending)
             .ToListAsync();
         var returnedUserIds = returnedTasks.Select(t => t.AssigneeId).Distinct().ToList();
-        await _notifyService.NotifyReturned(returnedUserIds, instance.Id, instance.Title, _userManager.RealName);
+        await _notifyService.NotifyReturned(returnedUserIds, instance.Id, instance.Title, _userManager.RealName, instance.BizType);
     }
 
     /// <summary>
@@ -319,7 +319,7 @@ public class FlowEngineService : ITransient
             $"{comment} → 加签给 {targetUser?.RealName}");
 
         var instance = await _instanceRep.GetByIdAsync(task.InstanceId);
-        await _notifyService.NotifyAddSign(targetUserId, task.InstanceId, instance?.Title ?? "", _userManager.RealName);
+        await _notifyService.NotifyAddSign(targetUserId, task.InstanceId, instance?.Title ?? "", _userManager.RealName, instance?.BizType ?? "");
     }
 
     /// <summary>
@@ -378,7 +378,7 @@ public class FlowEngineService : ITransient
 
         var targetUserIds = escalationApprovers.Select(a => a.userId).Distinct().ToList();
         await _notifyService.NotifyEscalated(targetUserIds, instance.Id, instance.Title,
-            _userManager.RealName, task.NodeName);
+            _userManager.RealName, task.NodeName, instance.BizType);
     }
 
     /// <summary>
@@ -397,7 +397,7 @@ public class FlowEngineService : ITransient
             .Where(t => t.InstanceId == instanceId && t.Status == FlowTaskStatusEnum.Pending)
             .ToListAsync();
         var userIds = pendingTasks.Select(t => t.AssigneeId).Distinct().ToList();
-        await _notifyService.NotifyUrge(userIds, instanceId, instance.Title);
+        await _notifyService.NotifyUrge(userIds, instanceId, instance.Title, instance.BizType);
     }
 
     // ═══════════════════════════════════════════
@@ -488,7 +488,7 @@ public class FlowEngineService : ITransient
         {
             case "Notify":
                 await _notifyService.NotifyTimeout(
-                    new List<long> { task.AssigneeId }, instance.Id, instance.Title);
+                    new List<long> { task.AssigneeId }, instance.Id, instance.Title, instance.BizType);
                 await WriteSystemLog(instance.Id, task.Id, task.NodeId,
                     FlowLogActionEnum.AutoTimeout, "审批超时,已发送提醒通知");
                 break;
@@ -544,7 +544,7 @@ public class FlowEngineService : ITransient
         await InvokeHandler(instance.BizType,
             h => h.OnFlowCompleted(instance.BizId, instance.Id, FlowInstanceStatusEnum.Rejected, null));
         await _notifyService.NotifyFlowCompleted(instance.InitiatorId, instance.Id,
-            instance.Title, FlowInstanceStatusEnum.Rejected);
+            instance.Title, FlowInstanceStatusEnum.Rejected, instance.BizType);
     }
 
     private async Task AutoEscalateTask(ApprovalFlowTask task, FlowProperties nodeProps, ApprovalFlowInstance instance)
@@ -588,7 +588,7 @@ public class FlowEngineService : ITransient
 
         var targetUserIds = approvers.Select(a => a.userId).Distinct().ToList();
         await _notifyService.NotifyEscalated(targetUserIds, instance.Id, instance.Title,
-            "系统", task.NodeName);
+            "系统", task.NodeName, instance.BizType);
     }
 
     private async Task WriteSystemLog(long instanceId, long? taskId, string? nodeId,
@@ -761,7 +761,7 @@ public class FlowEngineService : ITransient
             : null;
         await InvokeHandler(instance.BizType,
             h => h.OnFlowCompleted(instance.BizId, instance.Id, status, lastApproverId));
-        await _notifyService.NotifyFlowCompleted(instance.InitiatorId, instance.Id, instance.Title, status);
+        await _notifyService.NotifyFlowCompleted(instance.InitiatorId, instance.Id, instance.Title, status, instance.BizType);
     }
 
     /// <summary>
@@ -826,7 +826,7 @@ public class FlowEngineService : ITransient
         await _taskRep.AsInsertable(allTasks).ExecuteCommandAsync();
 
         var assigneeIds = allTasks.Select(t => t.AssigneeId).Distinct().ToList();
-        await _notifyService.NotifyNewTask(assigneeIds, instance.Id, instance.Title, nodeName);
+        await _notifyService.NotifyNewTask(assigneeIds, instance.Id, instance.Title, nodeName, instance.BizType);
     }
 
     /// <summary>

+ 237 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowNotify/FlowNotifyConfigService.cs

@@ -0,0 +1,237 @@
+using Admin.NET.Core.Service;
+using Microsoft.AspNetCore.SignalR;
+
+namespace Admin.NET.Plugin.ApprovalFlow.Service;
+
+/// <summary>
+/// 审批流通知渠道配置服务(P4-16 延伸)
+/// - 对外:List / Save(管理页使用) + TestSend(向当前登录用户发一条 SignalR 测试通知)
+/// - 对内:GetEffectiveAsync() → NotifyChannelConfig,供 FlowNotifyService 调用
+///   DB 有记录则以 DB 为准,无记录回退 ApprovalFlow.json(零破坏升级)
+/// </summary>
+[ApiDescriptionSettings(ApprovalFlowConst.GroupName, Order = 57)]
+public class FlowNotifyConfigService : IDynamicApiController, ITransient
+{
+    private readonly SqlSugarRepository<ApprovalFlowNotifyConfig> _rep;
+    private readonly SysCacheService _cache;
+    private readonly IHubContext<OnlineUserHub, IOnlineUserHub> _hubContext;
+    private readonly UserManager _userManager;
+
+    private const string CacheKey = "ApprovalFlow:NotifyConfig:All";
+
+    public FlowNotifyConfigService(
+        SqlSugarRepository<ApprovalFlowNotifyConfig> rep,
+        SysCacheService cache,
+        IHubContext<OnlineUserHub, IOnlineUserHub> hubContext,
+        UserManager userManager)
+    {
+        _rep = rep;
+        _cache = cache;
+        _hubContext = hubContext;
+        _userManager = userManager;
+    }
+
+    private static readonly string[] AllChannels = { "SignalR", "Email", "Sms", "DingTalk", "WorkWeixin" };
+
+    /// <summary>
+    /// 获取全部渠道配置(DB 中缺失的渠道用 ApprovalFlow.json 兜底值回填到返回结果)
+    /// </summary>
+    [HttpGet]
+    [ApiDescriptionSettings(Name = "List")]
+    [DisplayName("获取通知渠道配置")]
+    public async Task<List<NotifyChannelConfigOutput>> List()
+    {
+        var dbRows = await _rep.AsQueryable().ToListAsync();
+        var jsonCfg = App.GetConfig<NotifyChannelConfig>("ApprovalFlow:Notify") ?? new NotifyChannelConfig();
+
+        var result = new List<NotifyChannelConfigOutput>();
+        foreach (var ch in AllChannels)
+        {
+            var row = dbRows.FirstOrDefault(r => r.ChannelKey == ch);
+            if (row != null)
+            {
+                result.Add(new NotifyChannelConfigOutput
+                {
+                    Id = row.Id,
+                    ChannelKey = row.ChannelKey,
+                    Enabled = row.Enabled,
+                    WebhookUrl = row.WebhookUrl,
+                    Secret = row.Secret,
+                    TemplateId = row.TemplateId,
+                    Remark = row.Remark,
+                    Source = "DB",
+                });
+            }
+            else
+            {
+                result.Add(new NotifyChannelConfigOutput
+                {
+                    Id = 0,
+                    ChannelKey = ch,
+                    Enabled = ch switch
+                    {
+                        "SignalR" => jsonCfg.SignalR,
+                        "Email" => jsonCfg.Email,
+                        "Sms" => jsonCfg.Sms,
+                        "DingTalk" => jsonCfg.DingTalk,
+                        "WorkWeixin" => jsonCfg.WorkWeixin,
+                        _ => false,
+                    },
+                    WebhookUrl = ch switch
+                    {
+                        "DingTalk" => jsonCfg.DingTalkWebhookUrl,
+                        "WorkWeixin" => jsonCfg.WorkWeixinWebhookUrl,
+                        _ => null,
+                    },
+                    Secret = ch == "DingTalk" ? jsonCfg.DingTalkSecret : null,
+                    TemplateId = ch == "Sms" ? jsonCfg.SmsTemplateId : null,
+                    Remark = null,
+                    Source = "JSON",
+                });
+            }
+        }
+        return result;
+    }
+
+    /// <summary>
+    /// 保存某渠道配置(Upsert),保存后缓存失效,立即对 FlowNotifyService 生效
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "Save")]
+    [DisplayName("保存通知渠道配置")]
+    public async Task<long> Save(NotifyChannelConfigSaveInput input)
+    {
+        if (string.IsNullOrWhiteSpace(input.ChannelKey) || !AllChannels.Contains(input.ChannelKey))
+            throw Oops.Oh($"无效渠道标识:{input.ChannelKey}");
+
+        var entity = await _rep.AsQueryable().FirstAsync(r => r.ChannelKey == input.ChannelKey);
+        if (entity == null)
+        {
+            entity = new ApprovalFlowNotifyConfig
+            {
+                ChannelKey = input.ChannelKey,
+                Enabled = input.Enabled,
+                WebhookUrl = input.WebhookUrl,
+                Secret = input.Secret,
+                TemplateId = input.TemplateId,
+                Remark = input.Remark,
+            };
+            await _rep.InsertAsync(entity);
+        }
+        else
+        {
+            entity.Enabled = input.Enabled;
+            entity.WebhookUrl = input.WebhookUrl;
+            entity.Secret = input.Secret;
+            entity.TemplateId = input.TemplateId;
+            entity.Remark = input.Remark;
+            await _rep.UpdateAsync(entity);
+        }
+
+        InvalidateCache();
+        return entity.Id;
+    }
+
+    /// <summary>
+    /// 向当前登录用户发一条 SignalR 站内测试通知,便于管理员快速验证链路
+    /// (本轮仅覆盖 SignalR;外部渠道待需求明确后再加)
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "TestSend")]
+    [DisplayName("发送测试通知")]
+    public async Task<bool> TestSend()
+    {
+        var uid = _userManager.UserId;
+        var onlineUsers = _cache.HashGetAll<SysOnlineUser>(CacheConst.KeyUserOnline);
+        var connectionIds = onlineUsers
+            .Where(u => u.Value.UserId == uid)
+            .Select(u => u.Value.ConnectionId)
+            .ToList();
+        if (connectionIds.Count == 0) return false;
+
+        await _hubContext.Clients.Clients(connectionIds).ReceiveMessage(new
+        {
+            title = "【测试通知】审批流通知渠道连通性",
+            message = "这是一条管理员主动触发的测试通知,当前连接收到即表示 SignalR 站内消息链路通畅。",
+            type = FlowNotificationTypeEnum.Urge.ToString(),
+            instanceId = 0L,
+        });
+        return true;
+    }
+
+    // ═══════════════════════════════════════════
+    //  内部:向 FlowNotifyService 提供有效配置
+    // ═══════════════════════════════════════════
+
+    [NonAction]
+    public async Task<NotifyChannelConfig> GetEffectiveAsync()
+    {
+        var cached = _cache.Get<NotifyChannelConfig>(CacheKey);
+        if (cached != null) return cached;
+
+        var jsonCfg = App.GetConfig<NotifyChannelConfig>("ApprovalFlow:Notify") ?? new NotifyChannelConfig();
+        var dbRows = await _rep.AsQueryable().ToListAsync();
+
+        var cfg = new NotifyChannelConfig
+        {
+            SignalR = Pick(dbRows, "SignalR", r => r.Enabled, jsonCfg.SignalR),
+            Email = Pick(dbRows, "Email", r => r.Enabled, jsonCfg.Email),
+            Sms = Pick(dbRows, "Sms", r => r.Enabled, jsonCfg.Sms),
+            DingTalk = Pick(dbRows, "DingTalk", r => r.Enabled, jsonCfg.DingTalk),
+            WorkWeixin = Pick(dbRows, "WorkWeixin", r => r.Enabled, jsonCfg.WorkWeixin),
+
+            DingTalkWebhookUrl = PickStr(dbRows, "DingTalk", r => r.WebhookUrl, jsonCfg.DingTalkWebhookUrl),
+            DingTalkSecret = PickStr(dbRows, "DingTalk", r => r.Secret, jsonCfg.DingTalkSecret),
+            WorkWeixinWebhookUrl = PickStr(dbRows, "WorkWeixin", r => r.WebhookUrl, jsonCfg.WorkWeixinWebhookUrl),
+            SmsTemplateId = PickStr(dbRows, "Sms", r => r.TemplateId, jsonCfg.SmsTemplateId),
+        };
+
+        _cache.Set(CacheKey, cfg, TimeSpan.FromMinutes(10));
+        return cfg;
+    }
+
+    [NonAction]
+    public void InvalidateCache() => _cache.Remove(CacheKey);
+
+    private static bool Pick(List<ApprovalFlowNotifyConfig> rows, string key,
+        Func<ApprovalFlowNotifyConfig, bool> selector, bool fallback)
+    {
+        var row = rows.FirstOrDefault(r => r.ChannelKey == key);
+        return row != null ? selector(row) : fallback;
+    }
+
+    private static string? PickStr(List<ApprovalFlowNotifyConfig> rows, string key,
+        Func<ApprovalFlowNotifyConfig, string?> selector, string? fallback)
+    {
+        var row = rows.FirstOrDefault(r => r.ChannelKey == key);
+        return row != null ? selector(row) : fallback;
+    }
+}
+
+public class NotifyChannelConfigOutput
+{
+    public long Id { get; set; }
+    public string ChannelKey { get; set; } = "";
+    public bool Enabled { get; set; }
+    public string? WebhookUrl { get; set; }
+    public string? Secret { get; set; }
+    public string? TemplateId { get; set; }
+    public string? Remark { get; set; }
+    /// <summary>数据来源:DB(已保存过)/ JSON(未保存过,返回兜底值)</summary>
+    public string Source { get; set; } = "";
+}
+
+public class NotifyChannelConfigSaveInput
+{
+    [Required, MaxLength(16)]
+    public string ChannelKey { get; set; } = "";
+    public bool Enabled { get; set; }
+    [MaxLength(512)]
+    public string? WebhookUrl { get; set; }
+    [MaxLength(128)]
+    public string? Secret { get; set; }
+    [MaxLength(64)]
+    public string? TemplateId { get; set; }
+    [MaxLength(256)]
+    public string? Remark { get; set; }
+}

+ 92 - 112
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowNotify/FlowNotifyService.cs

@@ -3,27 +3,45 @@ using System.Diagnostics;
 namespace Admin.NET.Plugin.ApprovalFlow.Service;
 
 /// <summary>
-/// 流程通知服务(P4-16 重构)— 聚合所有 INotifyPusher,按配置 + 渠道启用状态并行调度,
-/// 每个渠道的发送结果写入 ApprovalFlowNotifyLog 便于运维追溯与降级审计。
+/// 流程通知服务(P4-16 重构 + 模板化/DB 配置)
+/// - 聚合所有 INotifyPusher,按渠道启用状态串行调度
+/// - 标题/正文优先走 FlowNotifyTemplateService 模板渲染(支持 {变量} + BizType 覆盖),未命中回退兜底文案
+/// - 渠道开关/凭据优先从 DB(ApprovalFlowNotifyConfig)读取,无记录回退 ApprovalFlow.json
+/// - 每次分发写 ApprovalFlowNotifyLog 便于运维追溯与降级审计
 /// </summary>
 public class FlowNotifyService : ITransient
 {
     private readonly IEnumerable<INotifyPusher> _pushers;
     private readonly SqlSugarRepository<ApprovalFlowNotifyLog> _logRep;
+    private readonly FlowNotifyTemplateService _tplService;
+    private readonly FlowNotifyConfigService _cfgService;
 
     public FlowNotifyService(
         IEnumerable<INotifyPusher> pushers,
-        SqlSugarRepository<ApprovalFlowNotifyLog> logRep)
+        SqlSugarRepository<ApprovalFlowNotifyLog> logRep,
+        FlowNotifyTemplateService tplService,
+        FlowNotifyConfigService cfgService)
     {
         _pushers = pushers;
         _logRep = logRep;
+        _tplService = tplService;
+        _cfgService = cfgService;
     }
 
     public async Task NotifyUsers(List<long> userIds, FlowNotification notification)
     {
         if (userIds.Count == 0) return;
 
-        var cfg = App.GetConfig<NotifyChannelConfig>("ApprovalFlow:Notify") ?? new NotifyChannelConfig();
+        // 模板渲染(未命中则用传入的 Title/Content 兜底)
+        var ctx = notification.Context ?? new Dictionary<string, string?>();
+        var rendered = await _tplService.RenderAsync(notification.Type, notification.BizType, ctx);
+        if (rendered != null)
+        {
+            notification.Title = rendered.Value.title;
+            notification.Content = rendered.Value.content;
+        }
+
+        var cfg = await _cfgService.GetEffectiveAsync();
 
         foreach (var pusher in _pushers)
         {
@@ -45,122 +63,80 @@ public class FlowNotifyService : ITransient
         }
     }
 
-    public async Task NotifyUrge(List<long> userIds, long instanceId, string title)
-    {
-        await NotifyUsers(userIds, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.Urge,
-            InstanceId = instanceId,
-            Title = $"【催办】{title}",
-            Content = "流程发起人催促您尽快审批,请及时处理。",
-        });
-    }
-
-    public async Task NotifyNewTask(List<long> userIds, long instanceId, string title, string? nodeName)
-    {
-        await NotifyUsers(userIds, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.NewTask,
-            InstanceId = instanceId,
-            Title = $"【待审批】{title}",
-            Content = $"您有一条新的审批任务({nodeName}),请及时处理。",
-        });
-    }
-
-    public async Task NotifyFlowCompleted(long initiatorId, long instanceId, string title, FlowInstanceStatusEnum finalStatus)
-    {
-        var statusText = finalStatus == FlowInstanceStatusEnum.Approved ? "已通过" : "已拒绝";
-        await NotifyUsers(new List<long> { initiatorId }, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.FlowCompleted,
-            InstanceId = instanceId,
-            Title = $"【审批{statusText}】{title}",
-            Content = $"您发起的审批流程已{statusText}。",
-        });
-    }
-
-    /// <summary>
-    /// 转办通知 — 通知被转办人
-    /// </summary>
-    public async Task NotifyTransferred(long targetUserId, long instanceId, string title, string? fromName)
-    {
-        await NotifyUsers(new List<long> { targetUserId }, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.Transferred,
-            InstanceId = instanceId,
-            Title = $"【转办】{title}",
-            Content = $"{fromName} 将一条审批任务转交给您,请及时处理。",
-        });
-    }
-
-    /// <summary>
-    /// 退回通知 — 通知被退回节点的审批人
-    /// </summary>
-    public async Task NotifyReturned(List<long> userIds, long instanceId, string title, string? returnedBy)
-    {
-        await NotifyUsers(userIds, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.Returned,
-            InstanceId = instanceId,
-            Title = $"【退回】{title}",
-            Content = $"{returnedBy} 已退回该审批,请重新审核。",
-        });
-    }
+    // ═══════════════════════════════════════════
+    //  事件专用便捷方法(FlowEngineService 调用)
+    //  每个方法收 bizType 作为最后一个参数;构造 FlowNotification 时带上变量上下文
+    // ═══════════════════════════════════════════
 
-    /// <summary>
-    /// 加签通知 — 通知被加签人
-    /// </summary>
-    public async Task NotifyAddSign(long targetUserId, long instanceId, string title, string? fromName)
-    {
-        await NotifyUsers(new List<long> { targetUserId }, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.AddSign,
-            InstanceId = instanceId,
-            Title = $"【加签】{title}",
-            Content = $"{fromName} 邀请您参与审批,请及时处理。",
-        });
-    }
+    public Task NotifyUrge(List<long> userIds, long instanceId, string title, string bizType = "")
+        => NotifyUsers(userIds, Build(FlowNotificationTypeEnum.Urge, instanceId, bizType,
+            fallbackTitle: $"【催办】{title}",
+            fallbackContent: "流程发起人催促您尽快审批,请及时处理。",
+            ctx: new() { ["title"] = title }));
 
-    /// <summary>
-    /// 升级通知 — 通知升级目标人
-    /// </summary>
-    public async Task NotifyEscalated(List<long> targetUserIds, long instanceId, string title, string? fromName, string? nodeName)
-    {
-        await NotifyUsers(targetUserIds, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.Escalated,
-            InstanceId = instanceId,
-            Title = $"【升级】{title}",
-            Content = $"{fromName} 将审批任务({nodeName})升级给您,请及时处理。",
-        });
-    }
+    public Task NotifyNewTask(List<long> userIds, long instanceId, string title, string? nodeName, string bizType = "")
+        => NotifyUsers(userIds, Build(FlowNotificationTypeEnum.NewTask, instanceId, bizType,
+            fallbackTitle: $"【待审批】{title}",
+            fallbackContent: $"您有一条新的审批任务({nodeName}),请及时处理。",
+            ctx: new() { ["title"] = title, ["nodeName"] = nodeName }));
 
-    /// <summary>
-    /// 超时提醒通知
-    /// </summary>
-    public async Task NotifyTimeout(List<long> userIds, long instanceId, string title)
+    public Task NotifyFlowCompleted(long initiatorId, long instanceId, string title, FlowInstanceStatusEnum finalStatus, string bizType = "")
     {
-        await NotifyUsers(userIds, new FlowNotification
-        {
-            Type = FlowNotificationTypeEnum.Timeout,
-            InstanceId = instanceId,
-            Title = $"【超时提醒】{title}",
-            Content = "您有一条审批任务已超时,请尽快处理。",
-        });
+        var statusText = finalStatus == FlowInstanceStatusEnum.Approved ? "已通过" : "已拒绝";
+        return NotifyUsers(new List<long> { initiatorId }, Build(FlowNotificationTypeEnum.FlowCompleted, instanceId, bizType,
+            fallbackTitle: $"【审批{statusText}】{title}",
+            fallbackContent: $"您发起的审批流程已{statusText}。",
+            ctx: new() { ["title"] = title, ["statusText"] = statusText }));
     }
 
-    /// <summary>
-    /// 撤回通知 — 通知被取消的审批人
-    /// </summary>
-    public async Task NotifyWithdrawn(List<long> userIds, long instanceId, string title, string? initiatorName)
+    public Task NotifyTransferred(long targetUserId, long instanceId, string title, string? fromName, string bizType = "")
+        => NotifyUsers(new List<long> { targetUserId }, Build(FlowNotificationTypeEnum.Transferred, instanceId, bizType,
+            fallbackTitle: $"【转办】{title}",
+            fallbackContent: $"{fromName} 将一条审批任务转交给您,请及时处理。",
+            ctx: new() { ["title"] = title, ["fromName"] = fromName }));
+
+    public Task NotifyReturned(List<long> userIds, long instanceId, string title, string? returnedBy, string bizType = "")
+        => NotifyUsers(userIds, Build(FlowNotificationTypeEnum.Returned, instanceId, bizType,
+            fallbackTitle: $"【退回】{title}",
+            fallbackContent: $"{returnedBy} 已退回该审批,请重新审核。",
+            ctx: new() { ["title"] = title, ["fromName"] = returnedBy }));
+
+    public Task NotifyAddSign(long targetUserId, long instanceId, string title, string? fromName, string bizType = "")
+        => NotifyUsers(new List<long> { targetUserId }, Build(FlowNotificationTypeEnum.AddSign, instanceId, bizType,
+            fallbackTitle: $"【加签】{title}",
+            fallbackContent: $"{fromName} 邀请您参与审批,请及时处理。",
+            ctx: new() { ["title"] = title, ["fromName"] = fromName }));
+
+    public Task NotifyEscalated(List<long> targetUserIds, long instanceId, string title, string? fromName, string? nodeName, string bizType = "")
+        => NotifyUsers(targetUserIds, Build(FlowNotificationTypeEnum.Escalated, instanceId, bizType,
+            fallbackTitle: $"【升级】{title}",
+            fallbackContent: $"{fromName} 将审批任务({nodeName})升级给您,请及时处理。",
+            ctx: new() { ["title"] = title, ["fromName"] = fromName, ["nodeName"] = nodeName }));
+
+    public Task NotifyTimeout(List<long> userIds, long instanceId, string title, string bizType = "")
+        => NotifyUsers(userIds, Build(FlowNotificationTypeEnum.Timeout, instanceId, bizType,
+            fallbackTitle: $"【超时提醒】{title}",
+            fallbackContent: "您有一条审批任务已超时,请尽快处理。",
+            ctx: new() { ["title"] = title }));
+
+    public Task NotifyWithdrawn(List<long> userIds, long instanceId, string title, string? initiatorName, string bizType = "")
+        => NotifyUsers(userIds, Build(FlowNotificationTypeEnum.Withdrawn, instanceId, bizType,
+            fallbackTitle: $"【已撤回】{title}",
+            fallbackContent: $"{initiatorName} 已撤回该审批流程。",
+            ctx: new() { ["title"] = title, ["initiatorName"] = initiatorName }));
+
+    private static FlowNotification Build(FlowNotificationTypeEnum type, long instanceId, string bizType,
+        string fallbackTitle, string fallbackContent, Dictionary<string, string?> ctx)
     {
-        await NotifyUsers(userIds, new FlowNotification
+        return new FlowNotification
         {
-            Type = FlowNotificationTypeEnum.Withdrawn,
+            Type = type,
             InstanceId = instanceId,
-            Title = $"【已撤回】{title}",
-            Content = $"{initiatorName} 已撤回该审批流程。",
-        });
+            BizType = bizType ?? "",
+            Title = fallbackTitle,
+            Content = fallbackContent,
+            Context = ctx,
+        };
     }
 
     // ═══════════════════════════════════════════
@@ -201,8 +177,12 @@ public class FlowNotification
 {
     public FlowNotificationTypeEnum Type { get; set; }
     public long InstanceId { get; set; }
+    /// <summary>业务类型,用于模板 BizType 级覆盖查找</summary>
+    public string BizType { get; set; } = "";
     public string Title { get; set; } = "";
     public string Content { get; set; } = "";
+    /// <summary>模板渲染上下文(变量表)</summary>
+    public Dictionary<string, string?>? Context { get; set; }
 }
 
 /// <summary>
@@ -222,7 +202,7 @@ public enum FlowNotificationTypeEnum
 }
 
 /// <summary>
-/// 通知渠道配置(来自 ApprovalFlow:Notify JSON)
+/// 通知渠道配置(来自 ApprovalFlow:Notify JSON 或 ApprovalFlowNotifyConfig 表的聚合结果
 /// </summary>
 public class NotifyChannelConfig
 {

+ 230 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowNotify/FlowNotifyTemplateService.cs

@@ -0,0 +1,230 @@
+using Admin.NET.Core.Service;
+using System.Text.RegularExpressions;
+
+namespace Admin.NET.Plugin.ApprovalFlow.Service;
+
+/// <summary>
+/// 审批流通知模板服务(P4-16 延伸)
+/// - 对外:CRUD(管理页使用) + EnsureSystemTemplates(启动种子)
+/// - 对内:RenderAsync(type, bizType, ctx) → (title, content),供 FlowNotifyService 调用
+/// </summary>
+[ApiDescriptionSettings(ApprovalFlowConst.GroupName, Order = 58)]
+public class FlowNotifyTemplateService : IDynamicApiController, ITransient
+{
+    private readonly SqlSugarRepository<ApprovalFlowNotifyTemplate> _rep;
+    private readonly SysCacheService _cache;
+
+    private const string CacheKey = "ApprovalFlow:NotifyTemplate:All";
+    private static readonly Regex VarRegex = new(@"\{(\w+)\}", RegexOptions.Compiled);
+
+    public FlowNotifyTemplateService(SqlSugarRepository<ApprovalFlowNotifyTemplate> rep, SysCacheService cache)
+    {
+        _rep = rep;
+        _cache = cache;
+    }
+
+    /// <summary>
+    /// 获取全部通知模板(含系统预置与业务覆盖)
+    /// </summary>
+    [HttpGet]
+    [ApiDescriptionSettings(Name = "List")]
+    [DisplayName("获取通知模板列表")]
+    public async Task<List<ApprovalFlowNotifyTemplate>> List([FromQuery] NotifyTemplateQueryInput? input = null)
+    {
+        return await _rep.AsQueryable()
+            .WhereIF(!string.IsNullOrEmpty(input?.NotifyType), t => t.NotifyType == input!.NotifyType)
+            .WhereIF(input?.BizType != null, t => t.BizType == input!.BizType)
+            .OrderBy(t => t.BizType)
+            .OrderBy(t => t.NotifyType)
+            .ToListAsync();
+    }
+
+    /// <summary>
+    /// 新增或更新通知模板(按 NotifyType + BizType 唯一)
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "Save")]
+    [DisplayName("保存通知模板")]
+    public async Task<long> Save(NotifyTemplateSaveInput input)
+    {
+        if (string.IsNullOrWhiteSpace(input.NotifyType)) throw Oops.Oh("NotifyType 必填");
+        if (string.IsNullOrWhiteSpace(input.Title)) throw Oops.Oh("标题模板必填");
+        if (string.IsNullOrWhiteSpace(input.Content)) throw Oops.Oh("正文模板必填");
+
+        var bizType = input.BizType ?? "";
+        var entity = await _rep.AsQueryable()
+            .FirstAsync(t => t.NotifyType == input.NotifyType && t.BizType == bizType);
+
+        if (entity == null)
+        {
+            entity = new ApprovalFlowNotifyTemplate
+            {
+                NotifyType = input.NotifyType,
+                BizType = bizType,
+                Title = input.Title,
+                Content = input.Content,
+                IsEnabled = input.IsEnabled ?? true,
+                IsSystem = false,
+                Remark = input.Remark,
+            };
+            await _rep.InsertAsync(entity);
+        }
+        else
+        {
+            entity.Title = input.Title;
+            entity.Content = input.Content;
+            entity.IsEnabled = input.IsEnabled ?? entity.IsEnabled;
+            entity.Remark = input.Remark;
+            await _rep.UpdateAsync(entity);
+        }
+
+        InvalidateCache();
+        return entity.Id;
+    }
+
+    /// <summary>
+    /// 删除通知模板(系统预置不可删)
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "Delete")]
+    [DisplayName("删除通知模板")]
+    public async Task Delete(BaseIdInput input)
+    {
+        var entity = await _rep.GetByIdAsync(input.Id) ?? throw Oops.Oh("模板不存在");
+        if (entity.IsSystem) throw Oops.Oh("系统预置模板不可删除(可改为停用)");
+        await _rep.DeleteByIdAsync(input.Id);
+        InvalidateCache();
+    }
+
+    /// <summary>
+    /// 可用变量说明(管理页展示)
+    /// </summary>
+    [HttpGet]
+    [ApiDescriptionSettings(Name = "Variables")]
+    [DisplayName("获取可用变量列表")]
+    public List<NotifyTemplateVariableOutput> Variables()
+    {
+        return new()
+        {
+            new() { Key = "title",          Description = "流程标题(业务单据主标题)", AppliedTo = "全部" },
+            new() { Key = "nodeName",       Description = "当前/相关节点名",           AppliedTo = "NewTask / Urge / Escalated / Timeout" },
+            new() { Key = "initiatorName",  Description = "流程发起人",                 AppliedTo = "Withdrawn / FlowCompleted" },
+            new() { Key = "fromName",       Description = "操作人(转办 / 退回 / 升级 / 加签发起者)", AppliedTo = "Transferred / Returned / AddSign / Escalated" },
+            new() { Key = "statusText",     Description = "\"已通过\" 或 \"已拒绝\"",   AppliedTo = "FlowCompleted" },
+        };
+    }
+
+    // ═══════════════════════════════════════════
+    //  渲染(供 FlowNotifyService 调用)
+    // ═══════════════════════════════════════════
+
+    /// <summary>
+    /// 按 (notifyType, bizType) 查找启用模板并渲染;未命中则返回 null,调用方走兜底文案。
+    /// 优先级:BizType 级覆盖 &gt; 全局默认(BizType="")
+    /// </summary>
+    [NonAction]
+    public async Task<(string title, string content)?> RenderAsync(
+        FlowNotificationTypeEnum type, string? bizType, Dictionary<string, string?> ctx)
+    {
+        var typeStr = type.ToString();
+        var all = await GetAllCachedAsync();
+
+        ApprovalFlowNotifyTemplate? tpl = null;
+        if (!string.IsNullOrEmpty(bizType))
+            tpl = all.FirstOrDefault(t => t.IsEnabled && t.NotifyType == typeStr && t.BizType == bizType);
+        tpl ??= all.FirstOrDefault(t => t.IsEnabled && t.NotifyType == typeStr && t.BizType == "");
+
+        if (tpl == null) return null;
+        return (Interpolate(tpl.Title, ctx), Interpolate(tpl.Content, ctx));
+    }
+
+    private static string Interpolate(string template, Dictionary<string, string?> ctx)
+    {
+        if (string.IsNullOrEmpty(template)) return template;
+        return VarRegex.Replace(template, m =>
+        {
+            var key = m.Groups[1].Value;
+            return ctx.TryGetValue(key, out var v) ? (v ?? "") : "";
+        });
+    }
+
+    private async Task<List<ApprovalFlowNotifyTemplate>> GetAllCachedAsync()
+    {
+        var cached = _cache.Get<List<ApprovalFlowNotifyTemplate>>(CacheKey);
+        if (cached != null) return cached;
+        var list = await _rep.AsQueryable().ToListAsync();
+        _cache.Set(CacheKey, list, TimeSpan.FromMinutes(10));
+        return list;
+    }
+
+    [NonAction]
+    public void InvalidateCache() => _cache.Remove(CacheKey);
+
+    // ═══════════════════════════════════════════
+    //  种子
+    // ═══════════════════════════════════════════
+
+    [NonAction]
+    public async Task EnsureSystemTemplates()
+    {
+        var exists = await _rep.AsQueryable().AnyAsync(t => t.IsSystem);
+        if (exists) return;
+
+        var defaults = new (string type, string title, string content, string remark)[]
+        {
+            ("NewTask",       "【待审批】{title}",       "您有一条新的审批任务({nodeName}),请及时处理。", "新任务创建"),
+            ("Urge",          "【催办】{title}",         "流程发起人催促您尽快审批,请及时处理。",           "催办"),
+            ("FlowCompleted", "【审批{statusText}】{title}", "您发起的审批流程已{statusText}。",            "流程完成"),
+            ("Transferred",   "【转办】{title}",         "{fromName} 将一条审批任务转交给您,请及时处理。",  "转办"),
+            ("Returned",      "【退回】{title}",         "{fromName} 已退回该审批,请重新审核。",             "退回"),
+            ("AddSign",       "【加签】{title}",         "{fromName} 邀请您参与审批,请及时处理。",           "加签"),
+            ("Withdrawn",     "【已撤回】{title}",       "{initiatorName} 已撤回该审批流程。",               "撤回"),
+            ("Escalated",     "【升级】{title}",         "{fromName} 将审批任务({nodeName})升级给您,请及时处理。", "升级"),
+            ("Timeout",       "【超时提醒】{title}",     "您有一条审批任务已超时,请尽快处理。",              "超时"),
+        };
+
+        foreach (var (type, title, content, remark) in defaults)
+        {
+            await _rep.InsertAsync(new ApprovalFlowNotifyTemplate
+            {
+                NotifyType = type,
+                BizType = "",
+                Title = title,
+                Content = content,
+                IsEnabled = true,
+                IsSystem = true,
+                Remark = remark,
+            });
+        }
+
+        InvalidateCache();
+    }
+}
+
+public class NotifyTemplateQueryInput
+{
+    public string? NotifyType { get; set; }
+    public string? BizType { get; set; }
+}
+
+public class NotifyTemplateSaveInput
+{
+    [Required, MaxLength(32)]
+    public string NotifyType { get; set; } = "";
+    [MaxLength(32)]
+    public string? BizType { get; set; }
+    [Required, MaxLength(256)]
+    public string Title { get; set; } = "";
+    [Required, MaxLength(1024)]
+    public string Content { get; set; } = "";
+    public bool? IsEnabled { get; set; }
+    [MaxLength(256)]
+    public string? Remark { get; set; }
+}
+
+public class NotifyTemplateVariableOutput
+{
+    public string Key { get; set; } = "";
+    public string Description { get; set; } = "";
+    public string AppliedTo { get; set; } = "";
+}

+ 6 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Startup.cs

@@ -28,8 +28,14 @@ public class Startup : AppStartup
         db.CodeFirst.InitTables<ApprovalFlowDelegate>();
         db.CodeFirst.InitTables<ApprovalFlowCompletedNode>();
         db.CodeFirst.InitTables<ApprovalFlowTask>();
+        db.CodeFirst.InitTables<ApprovalFlowNotifyLog>();
+        db.CodeFirst.InitTables<ApprovalFlowNotifyTemplate>();
+        db.CodeFirst.InitTables<ApprovalFlowNotifyConfig>();
 
         var templateService = scope.ServiceProvider.GetRequiredService<FlowCommentTemplateService>();
         templateService.EnsureSystemTemplates().GetAwaiter().GetResult();
+
+        var notifyTemplateService = scope.ServiceProvider.GetRequiredService<FlowNotifyTemplateService>();
+        notifyTemplateService.EnsureSystemTemplates().GetAwaiter().GetResult();
     }
 }