Selaa lähdekoodia

feat(s8): 业务化规则责任与通知配置

规则弹窗第三页从"处理与审核"改为"责任分配":业务用户只回答三个问题 ——
谁处理、谁复核、超时找谁。三个池都是账号多选,各自带空池后果说明
(不配会怎样),失效成员不静默丢弃而是标出来。

审批流的技术实现(节点 / 审批人类型 / 角色编码 / 解析结果)挪进默认折叠的
"高级信息 / 流程诊断",保留只读展示是为了排查"任务到底发给了谁",
但正常业务视图里不再出现这些术语。

第四页从"通知与升级"改为"通知设置":谁负责升级已经在责任分配里配了,
这里只回答"什么事件需要提醒"。提醒对象由责任关系推导、只读展示业务文案,
用户只有一个开关。历史手工配置与默认推导不同的行会标"自定义",不静默改写。
渠道不做假开关 —— 站内信与实时推送目前随通知一起发出,不能单独配。

提交复核的检验人选择器不再从全租户账号里挑,改为该规则的复核池 ∩ 审核权限,
候选与服务端校验共用同一个后端端点。人工提报没有来源规则时回落全租户,
并在提示文案里说明这一点,不让用户以为复核池没生效。
YY968XX 5 päivää sitten
vanhempi
commit
61dfbd4441

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.389",
+	"version": "2.4.390",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 21 - 0
Web/src/views/aidop/s8/api/s8ConfigApi.ts

@@ -368,6 +368,19 @@ export const s8ConfigApi = {
 			service.put(`/api/aidop/s8/config/watch-rules/${ruleId}/handler-pool`, withAidopTenantFactoryParams({ userIds })).then(unwrap),
 	},
 
+	/**
+	 * S8-RESPONSIBILITY-POOL-1:规则的三个责任池(处理 / 复核 / 升级)。
+	 *
+	 * 三类共用同一套端点 —— 存储差异(处理池仍在存量表)被收敛在服务端,
+	 * 前端不需要知道,也就不会在页面里长出三段各写各的调用。
+	 */
+	responsibility: {
+		get: (ruleId: number, type: 'HANDLER' | 'REVIEWER' | 'ESCALATION') =>
+			service.get(`/api/aidop/s8/config/watch-rules/${ruleId}/responsibility/${type}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		set: (ruleId: number, type: 'HANDLER' | 'REVIEWER' | 'ESCALATION', userIds: number[]) =>
+			service.put(`/api/aidop/s8/config/watch-rules/${ruleId}/responsibility/${type}`, withAidopTenantFactoryParams({ userIds })).then(unwrap),
+	},
+
 	/** S8-ACTION-PERMISSION-1:异常操作权限(全局配置,非规则级)。 */
 	actionPermissions: {
 		matrix: () => service.get<S8ActionRoleRow[]>('/api/aidop/s8/config/action-permissions', { params: withAidopTenantFactoryParams({}) }).then(unwrap),
@@ -406,6 +419,14 @@ export const s8ConfigApi = {
 			service.get(`/api/aidop/s8/config/watch-rules/${ruleId}/notify-recipients`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 		setNotifyRecipients: (ruleId: number, eventCode: string, body: { recipientTypes: string[]; specificUserIds: number[] }) =>
 			service.put(`/api/aidop/s8/config/watch-rules/${ruleId}/notify-recipients/${eventCode}`, withAidopTenantFactoryParams(body)).then(unwrap),
+		/**
+		 * S8-RESPONSIBILITY-POOL-1:「通知设置」页 —— 只回答「什么事件需要提醒」。
+		 * 提醒谁由后端按责任关系推导,前端只拿到业务文案 + 开关状态。
+		 */
+		notifyEvents: (ruleId: number) =>
+			service.get(`/api/aidop/s8/config/watch-rules/${ruleId}/notify-events`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		setNotifyEvent: (ruleId: number, eventCode: string, enabled: boolean) =>
+			service.put(`/api/aidop/s8/config/watch-rules/${ruleId}/notify-events/${eventCode}`, withAidopTenantFactoryParams({ enabled })).then(unwrap),
 		/** S8-RULE-4TAB-1:只读审核来源(不改任何审批语义,只呈现当前事实)。 */
 		reviewSource: (ruleId: number) =>
 			service.get(`/api/aidop/s8/config/watch-rules/${ruleId}/review-source`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),

+ 9 - 0
Web/src/views/aidop/s8/api/s8ExceptionApi.ts

@@ -114,6 +114,15 @@ export const s8ExceptionApi = {
 	 */
 	users: (requiredAction?: string) =>
 		service.get('/api/aidop/s8/master-data/users', { params: withAidopTenantFactoryParams(requiredAction ? { requiredAction } : {}) }).then(unwrap),
+	/**
+	 * S8-RESPONSIBILITY-POOL-1:该异常可选的复核人。
+	 *
+	 * 候选 = 该规则的复核账号池 ∩ 审核权限 ∩ 可用账号,与提交复核的服务端校验同源。
+	 * 刻意不复用上面的 `users('EXCEPTION_VERIFY')` —— 那是全租户口径,
+	 * 会把不负责这条规则的人也列进来,选了必被拒。
+	 */
+	verifierCandidates: (id: number) =>
+		service.get(`/api/aidop/s8/exceptions/${id}/verifier-candidates`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	/** 认领:处理人恒为当前登录账号,请求体只带备注。 */
 	claim: (id: number, body: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/claim`, withAidopTenantFactoryParams(body)).then(unwrap),

+ 169 - 140
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -153,43 +153,75 @@ const graceMax = computed(() => {
 	return max >= 2147483647 ? undefined : max;
 });
 
-// ── S8-HANDLER-POOL-1:规则处理账号池 ──
-// 责任维度(谁负责这条规则),与「异常操作权限」的角色维度正交。
-// P1-D 会把它搬进四页签弹窗的 Tab3;本批先在既有抽屉里给出可用入口,
-// 否则新加的启用门禁会让所有规则都停在"请先配置处理账号"而无处可配。
-const handlerPool = ref<number[]>([]);
-const handlerPoolLoaded = ref(false);
-const handlerPoolInvalid = ref<S8HandlerUser[]>([]);
-const savingPool = ref(false);
+// ── S8-RESPONSIBILITY-POOL-1:规则的三个责任池 ──
+//
+// 责任维度(谁负责什么)与「异常操作权限」的角色维度正交:
+// 角色管"能做什么",责任池管"负责什么",状态机管"现在能不能做"。
+// 页面上只出现业务语言,不出现 Role / ApproverType / RecipientType 这类技术术语。
+type PoolKey = 'HANDLER' | 'REVIEWER' | 'ESCALATION';
+
+interface PoolState {
+	key: PoolKey;
+	title: string;
+	description: string;
+	/** 空池时的后果说明 —— 让管理员知道不配会怎样,而不是只看到一个红框。 */
+	emptyWarning: string;
+	members: number[];
+	invalid: S8HandlerUser[];
+	loaded: boolean;
+	saving: boolean;
+}
+
+function newPool(key: PoolKey, title: string, description: string, emptyWarning: string): PoolState {
+	return { key, title, description, emptyWarning, members: [], invalid: [], loaded: false, saving: false };
+}
+
+const pools = ref<PoolState[]>([
+	newPool('HANDLER', '处理人员', '以下账号负责处理该规则产生的异常。',
+		'未配置处理人员时该规则无法启用 —— 否则异常会源源不断建出来、没有人认领。'),
+	newPool('REVIEWER', '复核人员', '处理人提交复核时,只能从以下账号中选择复核人。',
+		'未配置复核人员时该规则无法启用 —— 否则处理人提交复核时选不到任何人,单据会卡住。'),
+	newPool('ESCALATION', '升级人员', '异常超时或触发升级时通知以下账号。',
+		'未配置升级人员时该规则无法启用 —— 否则异常超时后不会通知任何人。'),
+]);
+
 const tenantUsers = ref<{ id: number; name: string; account?: string }[]>([]);
 
-async function loadHandlerPool(ruleId: number) {
-	handlerPoolLoaded.value = false;
-	handlerPoolInvalid.value = [];
-	try {
-		const [members, users] = await Promise.all([
-			s8ConfigApi.handlerPool.get(ruleId),
-			s8ExceptionApi.users(),
-		]);
-		const list = (members as S8HandlerUser[]) ?? [];
-		handlerPool.value = list.map((m) => Number(m.userId));
-		// 失效成员不静默丢弃:管理员必须看见"这里有一个人已经不算数了"。
-		handlerPoolInvalid.value = list.filter((m) => !m.valid);
-		tenantUsers.value = (users as typeof tenantUsers.value) ?? [];
-	} finally {
-		handlerPoolLoaded.value = true;
-	}
+function poolOf(key: PoolKey) {
+	return pools.value.find((p) => p.key === key)!;
 }
 
-async function saveHandlerPool() {
+async function loadPools(ruleId: number) {
+	pools.value.forEach((p) => {
+		p.loaded = false;
+		p.invalid = [];
+	});
+	const [users] = await Promise.all([
+		s8ExceptionApi.users(),
+		...pools.value.map(async (p) => {
+			try {
+				const r: any = await s8ConfigApi.responsibility.get(ruleId, p.key);
+				const list = (r?.members as S8HandlerUser[]) ?? [];
+				p.members = list.map((m) => Number(m.userId));
+				// 失效成员不静默丢弃:管理员必须看见"这里有一个人已经不算数了"。
+				p.invalid = list.filter((m) => !m.valid);
+			} finally {
+				p.loaded = true;
+			}
+		}),
+	]);
+	tenantUsers.value = (users as typeof tenantUsers.value) ?? [];
+}
+
+async function savePool(p: PoolState) {
 	if (!current.value) return;
-	savingPool.value = true;
+	p.saving = true;
 	try {
-		await s8ConfigApi.handlerPool.set(Number(current.value.id), handlerPool.value);
-		handlerPoolInvalid.value = [];
-		ElMessage.success(handlerPool.value.length ? '处理账号池已保存' : '已清空处理账号池(该规则将无法启用)');
+		await s8ConfigApi.responsibility.set(Number(current.value.id), p.key, p.members);
+		p.invalid = [];
+		ElMessage.success(p.members.length ? `${p.title}已保存` : `已清空${p.title}(该规则将无法启用)`);
 	} finally {
-		savingPool.value = false;
+		p.saving = false;
 	}
 }
 
@@ -200,42 +232,36 @@ const reviewLoaded = ref(false);
 const reviewStages = ref<any[]>([]);
 const escalateRoles = ref<string[]>([]);
 
-// ── S8-NOTIFY-RECIPIENT-1:事件收件人配置 ──
+// ── S8-RESPONSIBILITY-POOL-1:通知设置(只回答「什么事件需要提醒」)──
+//
+// 提醒谁由后端按责任关系推导(处理池 / 当前处理人 / 当前复核人 / 升级池),
+// 前端只展示业务文案 + 一个开关。刻意不再让业务用户挑收件人类型:
+// 规则已经知道这些人是谁,再让人配一遍就多出一处会与责任池不一致的配置。
 const notifyLoaded = ref(false);
 const notifyEvents = ref<any[]>([]);
-const notifyTypes = ref<string[]>([]);
 const savingNotify = ref('');
 
-/** 收件人类型的业务文案。刻意不出现「主管 / 总监」—— 系统里没有对应实体。 */
-const RECIPIENT_LABEL: Record<string, string> = {
-	HANDLER_POOL: '该规则的处理账号池',
-	ASSIGNEE: '当前处理人',
-	REVIEWER: '当前实际审核人',
-	SPECIFIC_USER: '指定账号',
-};
-
-async function loadNotifyRecipients(ruleId: number) {
+async function loadNotifyEvents(ruleId: number) {
 	notifyLoaded.value = false;
 	try {
-		const r: any = await s8ConfigApi.watchRules.notifyRecipients(ruleId);
+		const r: any = await s8ConfigApi.watchRules.notifyEvents(ruleId);
 		notifyEvents.value = r?.events ?? [];
-		notifyTypes.value = r?.recipientTypes ?? [];
 	} finally {
 		notifyLoaded.value = true;
 	}
 }
 
-async function saveNotifyEvent(row: any) {
+async function toggleNotifyEvent(row: any) {
 	if (!current.value) return;
 	savingNotify.value = row.eventCode;
 	try {
-		await s8ConfigApi.watchRules.setNotifyRecipients(Number(current.value.id), row.eventCode, {
-			recipientTypes: row.recipientTypes ?? [],
-			specificUserIds: row.specificUserIds ?? [],
-		});
-		ElMessage.success((row.recipientTypes?.length)
-			? `已保存「${row.displayName}」的收件人`
-			: `「${row.displayName}」已清空收件人(将回落旧分层配置)`);
+		await s8ConfigApi.watchRules.setNotifyEvent(Number(current.value.id), row.eventCode, row.enabled);
+		ElMessage.success(row.enabled
+			? `已开启「${row.displayName}」提醒,通知${row.recipientLabel}`
+			: `已关闭「${row.displayName}」提醒`);
+	} catch (e) {
+		row.enabled = !row.enabled;   // 保存失败回滚开关,避免页面显示与实际配置不符
+		throw e;
 	} finally {
 		savingNotify.value = '';
 	}
@@ -272,9 +298,9 @@ function openDetail(row: S8WatchRuleConfigRow) {
 	preview.value = null;
 	drawerOpen.value = true;
 	activeTab.value = 'info';
-	void loadHandlerPool(Number(row.id));
+	void loadPools(Number(row.id));
 	void loadReviewSource(Number(row.id));
-	void loadNotifyRecipients(Number(row.id));
+	void loadNotifyEvents(Number(row.id));
 }
 
 /**
@@ -667,136 +693,139 @@ async function provision() {
 						</div>
 					</el-tab-pane>
 
-					<!-- TAB 3 · 处理与审核:责任维度(可写)+ 审核来源(只读事实)。 -->
-					<el-tab-pane label="处理与审核" name="people">
-						<!-- S8-HANDLER-POOL-1:责任维度。谁负责这条规则,与「异常操作权限」的角色维度正交。 -->
-						<el-card shadow="never" style="margin-bottom: 12px">
+					<!--
+						TAB 3 · 责任分配:业务用户只回答三个问题 ——
+						谁处理、谁复核、超时找谁。三者都是 SysUser 多选,成员一律经
+						同租户 + 启用校验。技术实现(审批流节点 / 审批人类型 / 角色编码)
+						挪进「高级信息」折叠区,默认不展开、且只读。
+					-->
+					<el-tab-pane label="责任分配" name="people">
+						<el-card v-for="p in pools" :key="p.key" shadow="never" style="margin-bottom: 12px">
 							<template #header>
 								<div style="display: flex; justify-content: space-between; align-items: center">
-									<span style="font-weight: 600">处理账号(该规则由谁负责)</span>
-									<el-button type="primary" size="small" :loading="savingPool"
-										:disabled="!handlerPoolLoaded" @click="saveHandlerPool">保存处理账号</el-button>
+									<div>
+										<span style="font-weight: 600">{{ p.title }}</span>
+										<span style="color: #909399; font-size: 12px; margin-left: 8px">{{ p.description }}</span>
+									</div>
+									<el-button type="primary" size="small" :loading="p.saving"
+										:disabled="!p.loaded" @click="savePool(p)">保存</el-button>
 								</div>
 							</template>
-							<el-alert v-if="handlerPoolLoaded && handlerPool.length === 0" type="warning"
+
+							<el-alert v-if="p.loaded && p.members.length === 0" type="warning"
 								:closable="false" show-icon style="margin-bottom: 10px">
-								<template #title>该规则尚未配置处理账号</template>
-								<div style="line-height: 1.9">
-									没有处理账号的规则<strong>无法启用</strong>:它会源源不断建出异常、却没有人能认领,
-									而调度器每一轮都报成功。
-								</div>
+								<template #title>尚未配置{{ p.title }}</template>
+								<div style="line-height: 1.9">{{ p.emptyWarning }}</div>
 							</el-alert>
-							<el-alert v-if="handlerPoolInvalid.length" type="error" :closable="false" show-icon
+
+							<el-alert v-if="p.invalid.length" type="error" :closable="false" show-icon
 								style="margin-bottom: 10px">
-								<template #title>有 {{ handlerPoolInvalid.length }} 个已配置的账号当前不可用</template>
+								<template #title>有 {{ p.invalid.length }} 个已配置的账号当前不可用</template>
 								<div style="line-height: 1.9">
-									这些账号已停用或不在当前租户,<strong>不再具备认领资格</strong>。请重新选择后保存。
+									这些账号已停用或不在当前租户,<strong>已不再算数</strong>。请重新选择后保存。
 								</div>
 							</el-alert>
-							<el-select v-model="handlerPool" multiple filterable clearable collapse-tags collapse-tags-tooltip
-								placeholder="选择负责该规则的系统账号" style="width: 100%" :loading="!handlerPoolLoaded">
+
+							<el-select v-model="p.members" multiple filterable clearable collapse-tags collapse-tags-tooltip
+								:placeholder="`选择${p.title}`" style="width: 100%" :loading="!p.loaded">
 								<el-option v-for="u in tenantUsers" :key="u.id" :label="u.name" :value="u.id">
 									<span>{{ u.name }} <span style="color: #999; font-size: 12px">{{ u.account ? '(' + u.account + ')' : '' }}</span></span>
 								</el-option>
 							</el-select>
+
 							<div style="color: #909399; font-size: 12px; margin-top: 6px">
-								池成员才能认领 / 被转派该规则产生的异常。能否认领还同时取决于账号是否拥有「认领」动作权限,
-								以及单据当前状态。
+								当前有效:{{ p.members.length - p.invalid.length }} 人
+								<template v-if="p.key === 'HANDLER'">
+									· 分配方式:人员池认领(谁先认领归谁)
+								</template>
+								<template v-else-if="p.key === 'REVIEWER'">
+									· 复核人还需拥有「审核」操作权限才会出现在提交复核的候选里
+								</template>
 							</div>
 						</el-card>
 
 						<!--
-							审核区块**只读**。本批不新建 Rule 级 Reviewer Pool、不改 ApproverType、
-							不动流程节点、不改 owner/escalate 语义 —— 只如实说明当前审核人来自哪里。
-							此前这里显示的是「主管」,而系统里根本不存在组织主管关系
-							(SysOrg.DirectorId 与 SysUser.ManagerUserId 实测填充率均为 0)。
+							技术实现收进折叠区:业务用户不需要理解审批流。
+							保留只读展示是为了排查 —— 出问题时要能看到任务实际发给了谁。
 						-->
-						<el-card shadow="never" v-loading="!reviewLoaded">
-							<template #header>
-								<span style="font-weight: 600">审核来源(只读)</span>
-								<span style="color: #909399; font-size: 12px; margin-left: 8px">当前审核人由下列机制决定,本页不修改它们</span>
-							</template>
-							<div v-for="st in reviewStages" :key="st.stage" style="margin-bottom: 14px">
-								<div style="font-weight: 600; margin-bottom: 4px">{{ st.stage }}</div>
-								<div style="color: #606266; line-height: 1.9">{{ st.decidedBy }}</div>
-								<el-alert v-for="(w, wi) in st.warnings" :key="wi" type="warning" :closable="false" show-icon style="margin-top: 6px" :title="w" />
-								<el-table v-if="st.nodes.length" :data="st.nodes" size="small" border style="margin-top: 8px">
-									<el-table-column prop="nodeName" label="审批节点" width="150" />
-									<el-table-column prop="approverType" label="审批人类型" width="130" />
-									<el-table-column prop="approverIds" label="配置值" min-width="200" />
-									<el-table-column label="本租户实际解析" min-width="240">
-										<template #default="scope">
-											<span v-if="scope.row.resolvedUsers.length">{{ scope.row.resolvedUsers.join('、') }}</span>
-											<el-tag v-else type="danger" size="small">解析不出任何审批人</el-tag>
-											<div v-if="scope.row.crossTenantRefs.length" style="color: #e6a23c; font-size: 12px; margin-top: 4px">越界引用:{{ scope.row.crossTenantRefs.join('、') }}</div>
-										</template>
-									</el-table-column>
-								</el-table>
-							</div>
-							<el-divider v-if="escalateRoles.length" content-position="left">超时自动升级角色(来自异常类型配置)</el-divider>
-							<div v-for="(r, ri) in escalateRoles" :key="ri" style="color: #606266; line-height: 1.9">{{ r }}</div>
-							<div style="margin-top: 10px">
-								<el-button link type="primary" @click="goApprovalConfig">查看 / 修改审批流程配置</el-button>
-							</div>
-						</el-card>
+						<el-collapse>
+							<el-collapse-item name="diagnostics">
+								<template #title>
+									<span style="color: #909399">高级信息 / 流程诊断(一般不需要查看)</span>
+								</template>
+								<div v-loading="!reviewLoaded">
+									<el-alert type="info" :closable="false" show-icon style="margin-bottom: 10px">
+										<template #title>以下为审批流程的执行机制,<strong>只读</strong></template>
+										<div style="line-height: 1.9">
+											复核人由上方「复核人员」决定;提交复核时选定的账号会成为审批任务的实际处理人。
+											这里展示的是流程定义本身,用于排查"任务发给了谁"。
+										</div>
+									</el-alert>
+									<div v-for="st in reviewStages" :key="st.stage" style="margin-bottom: 14px">
+										<div style="font-weight: 600; margin-bottom: 4px">{{ st.stage }}</div>
+										<div style="color: #606266; line-height: 1.9">{{ st.decidedBy }}</div>
+										<el-alert v-for="(w, wi) in st.warnings" :key="wi" type="warning" :closable="false" show-icon style="margin-top: 6px" :title="w" />
+										<el-table v-if="st.nodes.length" :data="st.nodes" size="small" border style="margin-top: 8px">
+											<el-table-column prop="nodeName" label="审批节点" width="150" />
+											<el-table-column prop="approverType" label="审批人类型" width="130" />
+											<el-table-column prop="approverIds" label="配置值" min-width="200" />
+											<el-table-column label="本租户实际解析" min-width="240">
+												<template #default="scope">
+													<span v-if="scope.row.resolvedUsers.length">{{ scope.row.resolvedUsers.join('、') }}</span>
+													<el-tag v-else type="danger" size="small">解析不出任何审批人</el-tag>
+													<div v-if="scope.row.crossTenantRefs.length" style="color: #e6a23c; font-size: 12px; margin-top: 4px">越界引用:{{ scope.row.crossTenantRefs.join('、') }}</div>
+												</template>
+											</el-table-column>
+										</el-table>
+									</div>
+									<div style="margin-top: 10px">
+										<el-button link type="primary" @click="goApprovalConfig">查看 / 修改审批流程配置</el-button>
+									</div>
+								</div>
+							</el-collapse-item>
+						</el-collapse>
 					</el-tab-pane>
 
 					<!--
-						TAB 4 · 通知与升级:P1-E 的 UI 容器。
-						刻意不铺 L1_OPERATOR / L2_MANAGER / L3_DIRECTOR ——
-						那三档在库里只有 L1 真正被用过,且「主管 / 总监」没有对应业务实体。
+						TAB 4 · 通知设置:只回答「什么事件需要提醒」
+						「谁负责升级」已经在 Tab 3 配置,这里不再重复;
+						通知对象由责任关系推导,页面只读展示业务文案
 					-->
-					<el-tab-pane label="通知与升级" name="notify">
+					<el-tab-pane label="通知设置" name="notify">
 						<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
-							<template #title>收件人按<strong>事件</strong>配置,最终都投递到系统账号</template>
+							<template #title>提醒对象由<strong>责任分配</strong>自动决定</template>
 							<div style="line-height: 1.9">
-								未配置的事件会回落到旧的「通知分层配置」(按场景 + 严重度 → 角色)。
-								一旦这里配了任意收件人,该事件就<strong>完全不再</strong>使用旧配置。
+								不需要在这里再选一次人 —— 改「责任分配」里的三个池,这里的提醒对象会同步跟着变。
 							</div>
 						</el-alert>
 
 						<el-table :data="notifyEvents" v-loading="!notifyLoaded" border style="width: 100%">
-							<el-table-column prop="displayName" label="事件" width="130">
-								<template #default="scope">
-									<span>{{ scope.row.displayName }}</span>
-									<el-tag v-if="!(scope.row.recipientTypes || []).length" size="small" type="info" style="margin-left: 6px">回落旧配置</el-tag>
-								</template>
-							</el-table-column>
-							<el-table-column prop="triggerHint" label="触发时机" min-width="200">
+							<el-table-column prop="displayName" label="事件" width="140" />
+							<el-table-column prop="triggerHint" label="发生时机" min-width="200">
 								<template #default="scope">
 									<span style="color: #909399; font-size: 12px">{{ scope.row.triggerHint }}</span>
 								</template>
 							</el-table-column>
-							<el-table-column label="通知谁" min-width="260">
-								<template #default="scope">
-									<el-select v-model="scope.row.recipientTypes" multiple collapse-tags collapse-tags-tooltip
-										placeholder="未配置(回落旧分层配置)" style="width: 100%">
-										<el-option v-for="t in notifyTypes" :key="t" :label="RECIPIENT_LABEL[t] || t" :value="t" />
-									</el-select>
-								</template>
-							</el-table-column>
-							<el-table-column label="指定账号" min-width="230">
+							<el-table-column label="通知对象" min-width="160">
 								<template #default="scope">
-									<el-select v-if="(scope.row.recipientTypes || []).includes('SPECIFIC_USER')"
-										v-model="scope.row.specificUserIds" multiple filterable collapse-tags collapse-tags-tooltip
-										placeholder="选择账号" style="width: 100%">
-										<el-option v-for="u in tenantUsers" :key="u.id" :label="u.name" :value="u.id" />
-									</el-select>
-									<span v-else style="color: #c0c4cc; font-size: 12px">仅「指定账号」需要</span>
+									<span>{{ scope.row.recipientLabel }}</span>
+									<el-tooltip v-if="!scope.row.matchesDerivedDefault" placement="top"
+										content="该事件存在历史手工配置,与默认推导不同。重新开关一次即可恢复默认。">
+										<el-tag size="small" type="warning" style="margin-left: 6px">自定义</el-tag>
+									</el-tooltip>
 								</template>
 							</el-table-column>
-							<el-table-column label="操作" width="90" fixed="right">
+							<el-table-column label="是否提醒" width="110" fixed="right">
 								<template #default="scope">
-									<el-button type="primary" link :loading="savingNotify === scope.row.eventCode"
-										@click="saveNotifyEvent(scope.row)">保存</el-button>
+									<el-switch v-model="scope.row.enabled" :loading="savingNotify === scope.row.eventCode"
+										@change="toggleNotifyEvent(scope.row)" />
 								</template>
 							</el-table-column>
 						</el-table>
 
 						<div style="color: #909399; font-size: 12px; margin-top: 10px; line-height: 1.9">
-							「当前实际审核人」解析的是单据上已手选的检验人;尚未提交复核时回落到上方
-							「审核来源」列出的审批流审批人 —— 它不是一个新的审核人员池。<br />
-							可用渠道:站内信、SignalR。
+							通知通过站内信与实时推送送达,两者随通知一起发出、目前不单独开关 ——
+							给一个点了没有实际效果的渠道开关,比不给更糟。
 						</div>
 					</el-tab-pane>
 

+ 28 - 8
Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue

@@ -166,19 +166,28 @@ async function loadUsers() {
 }
 
 /**
- * S8-UAT-APPROVAL-REPAIR-1:检验人候选<b>只列具备「审核」权限的账号</b>。
+ * S8-RESPONSIBILITY-POOL-1:检验人候选 = <b>该规则的复核账号池 ∩ 审核权限 ∩ 可用账号</b>。
  *
- * 复核审批任务是按「该租户被授予审核权限的角色」派发的;如果这里还能选到
- * 没有审核权限的人,就会出现本地实测过的错位:页面上选了甲,审批任务却发给乙那一组,
- * 甲手里没有任务、乙又过不了 S8 的 verifier_user_id 校验,两边互相卡住且都不报错。
+ * 不再从全租户账号里挑。候选由后端一个端点给出,与提交复核的服务端校验<b>同一份实现</b>——
+ * 各写一套的后果本地实测过:页面上选了甲,审批任务却发给另一组人,
+ * 甲手里没有任务、那组人又过不了 S8 的 verifier_user_id 校验,两边互相卡住且都不报错。
+ *
+ * 人工提报的异常没有来源规则,候选回落为本租户内有审核权限的账号(fromReviewerPool=false)。
  */
 const verifierUsers = ref<{ id: number; name: string; account?: string }[]>([]);
 const verifierLoaded = ref(false);
+const verifierFromPool = ref(true);
 
 async function loadVerifierCandidates() {
 	verifierLoaded.value = false;
 	try {
-		verifierUsers.value = (await s8ExceptionApi.users('EXCEPTION_VERIFY')) as typeof verifierUsers.value;
+		const r: any = await s8ExceptionApi.verifierCandidates(Number(route.params.id ?? detail.value?.id));
+		verifierFromPool.value = r?.fromReviewerPool !== false;
+		verifierUsers.value = (r?.candidates ?? []).map((c: any) => ({
+			id: Number(c.userId),
+			name: c.displayName,
+			account: c.account,
+		}));
 	} finally {
 		verifierLoaded.value = true;
 	}
@@ -530,16 +539,27 @@ onMounted(async () => {
 				</el-form-item>
 				<el-form-item v-if="dialogMode === 'submitVerification'" label="检验人" required>
 					<el-select v-model="actionForm.targetUserId" style="width: 100%" filterable clearable
-						:loading="!verifierLoaded" placeholder="选择具备审核权限的账号">
+						:loading="!verifierLoaded" placeholder="选择复核人">
 						<el-option v-for="item in verifierUsers" :key="item.id" :label="item.name" :value="item.id">
 							<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.account ? '(' + item.account + ')' : '' }}</span></span>
 						</el-option>
 					</el-select>
 					<div v-if="verifierLoaded && verifierUsers.length === 0" style="color:#e6a23c;font-size:12px;line-height:1.9">
-						本租户没有任何账号具备「审核」权限,无法提交复核。请先在「异常操作权限」中为某个角色授予「审核」。
+						<template v-if="verifierFromPool">
+							该规则没有可用的复核人员,无法提交复核。请让管理员在规则的「责任分配」中配置复核人员,
+							并确认这些账号拥有「审核」操作权限。
+						</template>
+						<template v-else>
+							本租户没有任何账号具备「审核」权限,无法提交复核。请先在「异常操作权限」中为某个角色授予「审核」。
+						</template>
 					</div>
 					<div v-else style="color:#909399;font-size:12px;line-height:1.9">
-						只列出具备「审核」权限的账号 —— 复核审批任务正是按这些账号所属的角色派发的。
+						<template v-if="verifierFromPool">
+							只列出该规则的复核人员 —— 选定后,复核任务就会直接派给这个人。
+						</template>
+						<template v-else>
+							本单为人工提报、没有来源规则,因此列出本租户全部具备「审核」权限的账号。
+						</template>
 					</div>
 				</el-form-item>
 				<el-form-item