Bläddra i källkod

feat(s8): 重构规则四页签配置体验

规则配置原来是一个 720px 抽屉,把「只读定义 / 运行参数 / 预演 / 运行状态 /
处理人员池」竖着堆在一起,管理员要滚很久才能找到要改的那一项,也分不清哪些是
他能改的、哪些是系统给的。改成四个页签,各自回答一个问题:
这条规则是什么 / 它怎么跑 / 谁负责与谁审 / 出事通知谁。

「异常操作权限」刻意不放进来——它是 S8 全局配置(哪个角色能做认领这类动作),
放进规则弹窗会让管理员以为改的是这条规则的权限,实际改的是全租户。

Tab 3 的「审核」区块是本批唯一新增的后端能力,而且**只读**:
按已锁定的产品决策,不新建 Rule 级 Reviewer Pool、不改 ApproverType、
不动审批流节点、不改 owner/escalate 语义。它只做一件事——把当前事实如实呈现,
包括那些不好看的事实。

为什么必须做:这里此前显示的是「主管」,而系统里根本不存在组织主管关系
(SysOrg.DirectorId 与 SysUser.ManagerUserId 实测填充率均为 0)。管理员据此
以为审核链是通的。本地取证显示实际情况是:

- 复核 EXCEPTION_CLOSURE:approverType=Role, approverIds=1300000000101,1300000000888,
  这两个角色属于别的租户,在本租户下解析为 0 人;而真正决定谁能点「检验通过」的
  是提交复核时手选写入的 verifier_user_id——页面必须说清楚这一点,
  否则管理员会去改流程配置然后发现毫无效果。
- 升级 EXCEPTION_ESCALATION:三个节点全部 SpecificUser=789819678195781(Demo02,
  默认租户),同样解析不出本租户审批人。
- 异常类型的 escalate_role_code 与 owner_role_code 实测几乎全部相同,
  即「升级」升给了同一批人——一并如实标注,不修改。

节点解析走宽松读取:认不出的结构一律跳过、绝不猜;整体解析失败时显示
「无法确定审批人来源」而不是「没有审批人」。解析为 0 人与跨租户引用都必须
显式暴露——让「审核链其实是断的」在页面上看得见,正是这个视图存在的意义。

Tab 4 只放 P1-E 的容器,刻意不铺 L1_OPERATOR / L2_MANAGER / L3_DIRECTOR:
那三档在库里只有 L1 真正被用过,且「主管 / 总监」没有对应业务实体。

本地验证:四页签渲染正常、Header 带规则名与启用状态;Tab 3 如实列出上述两条
断裂的审批链并标注越界引用;全程无「主管 / 总监」文案;控制台无报错。
YY968XX 2 dagar sedan
förälder
incheckning
9c8c70ff9b

+ 1 - 1
Web/package.json

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

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

@@ -401,6 +401,9 @@ export const s8ConfigApi = {
 			service.delete(`/api/aidop/s8/config/exception-types/${overrideId}/factory-override`).then(unwrap),
 	},
 	watchRules: {
+		/** S8-RULE-4TAB-1:只读审核来源(不改任何审批语义,只呈现当前事实)。 */
+		reviewSource: (ruleId: number) =>
+			service.get(`/api/aidop/s8/config/watch-rules/${ruleId}/review-source`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 		list: (params?: { tenantId?: number; factoryId?: number }) =>
 			service.get<S8WatchRuleConfigRow[]>('/api/aidop/s8/config/watch-rules', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
 		// S8-RULE-GOVERNANCE-BATCH3/4:create 已删除——规则由系统版本定义,前端不再暴露创建能力。

+ 259 - 155
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -19,6 +19,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import { s8ConfigApi, type S8HandlerUser, type S8RuleParametersPayload, type S8WatchRuleConfigRow, type S8WatchRulePreviewResult } from '../api/s8ConfigApi';
 import { s8ExceptionApi } from '../api/s8ExceptionApi';
+import { useRouter } from 'vue-router';
 import { s8ReportApi } from '../api/s8ReportApi';
 
 const loading = ref(false);
@@ -192,6 +193,29 @@ async function saveHandlerPool() {
 	}
 }
 
+// ── S8-RULE-4TAB-1:四页签状态 + 只读审核来源 ──
+const router = useRouter();
+const activeTab = ref('info');
+const reviewLoaded = ref(false);
+const reviewStages = ref<any[]>([]);
+const escalateRoles = ref<string[]>([]);
+
+async function loadReviewSource(ruleId: number) {
+	reviewLoaded.value = false;
+	try {
+		const r: any = await s8ConfigApi.watchRules.reviewSource(ruleId);
+		reviewStages.value = r?.stages ?? [];
+		escalateRoles.value = r?.escalateRoles ?? [];
+	} finally {
+		reviewLoaded.value = true;
+	}
+}
+
+/** 审批流程配置归流程管理维护;本页只读展示,改配置去那边,不在规则弹窗里改。 */
+function goApprovalConfig() {
+	router.push('/aidop/flowManage/approvalFlowList');
+}
+
 function openDetail(row: S8WatchRuleConfigRow) {
 	current.value = row;
 	form.pollIntervalSeconds = row.pollIntervalSeconds;
@@ -204,7 +228,9 @@ function openDetail(row: S8WatchRuleConfigRow) {
 	snapshot = JSON.stringify(form);
 	preview.value = null;
 	drawerOpen.value = true;
+	activeTab.value = 'info';
 	void loadHandlerPool(Number(row.id));
+	void loadReviewSource(Number(row.id));
 }
 
 /**
@@ -446,169 +472,247 @@ async function provision() {
 			</el-table-column>
 		</el-table>
 
-		<!-- 详情抽屉:A 基础定义(只读) / B 运行参数 / C 预演 / D 运行状态 -->
-		<el-drawer v-model="drawerOpen" size="720px" :title="current ? ruleName(current) : ''">
-			<template v-if="current">
-				<!-- S8-HANDLER-POOL-1:责任维度。谁负责这条规则,与「异常操作权限」的角色维度正交。 -->
-				<el-card 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>
-					</template>
-					<el-alert v-if="handlerPoolLoaded && handlerPool.length === 0" type="warning"
-						:closable="false" show-icon style="margin-bottom: 10px">
-						<template #title>该规则尚未配置处理账号</template>
-						<div style="line-height: 1.9">
-							没有处理账号的规则<strong>无法启用</strong>:它会源源不断建出异常、却没有人能认领,
-							而调度器每一轮都报成功。
-						</div>
-					</el-alert>
-					<el-alert v-if="handlerPoolInvalid.length" type="error" :closable="false" show-icon
-						style="margin-bottom: 10px">
-						<template #title>有 {{ handlerPoolInvalid.length }} 个已配置的账号当前不可用</template>
-						<div style="line-height: 1.9">
-							这些账号已停用或不在当前租户,<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-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">
-						池成员才能认领 / 被转派该规则产生的异常。能否认领还同时取决于账号是否拥有「认领」动作权限,
-						以及单据当前状态。
-					</div>
-				</el-card>
+		<!--
+			S8-RULE-4TAB-1:规则配置弹窗统一为四页签。
+
+			原来是一个 720px 抽屉,把「只读定义 / 运行参数 / 预演 / 运行状态 / 处理人员池」
+			竖着堆在一起,管理员要滚很久才能找到要改的那一项,也分不清哪些是他能改的、
+			哪些是系统给的。四个页签各自回答一个问题:
+			  这条规则是什么 / 它怎么跑 / 谁负责与谁审 / 出事通知谁。
+
+			「异常操作权限」刻意**不**放进来 —— 它是 S8 全局配置(哪个角色能做认领这类动作),
+			放进规则弹窗会让管理员以为改的是这条规则的权限,实际改的是全租户。
+		-->
+		<el-dialog v-model="drawerOpen" width="980px" top="5vh" destroy-on-close>
+			<template v-if="current" #header>
+				<div style="display: flex; align-items: center; gap: 10px">
+					<span style="font-weight: 600; font-size: 16px">{{ ruleName(current) }}</span>
+					<el-tag :type="current.enabled ? 'success' : 'info'" size="small">{{ current.enabled ? '已启用' : '已停用' }}</el-tag>
+					<span style="color: #909399; font-size: 12px">{{ current.ruleCode }}</span>
+				</div>
 			</template>
+
 			<template v-if="current">
 				<el-alert v-if="current.definitionMissing" type="error" :closable="false" show-icon
 					title="当前版本未找到规则定义"
 					description="该规则在当前系统版本中已无对应定义,无法调整参数、启用或预演。请联系实施人员确认版本。" />
 
-				<!-- A. 基础定义:由系统版本定义,全只读。
-				     刻意用 descriptions 而非 disabled input——后者会让人以为"暂时不能编辑"。 -->
-				<el-divider content-position="left">基础定义(由系统版本定义)</el-divider>
-				<el-descriptions :column="2" border size="small">
-					<el-descriptions-item label="规则名称">{{ current.displayName ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="规则编码">{{ current.ruleCode }}</el-descriptions-item>
-					<el-descriptions-item label="业务说明" :span="2">{{ current.description ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="数据集">{{ current.datasetDisplayName ?? current.datasetCode ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="业务对象">{{ current.sourceObjectType ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="规则类型">{{ current.ruleType ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="报警机制">{{ current.ruleMechanism ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="场景">{{ current.sceneCode ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="阶段">{{ current.stageCode ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="订单流程">{{ current.orderFlowCode ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="异常类型">{{ current.exceptionTypeCode ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="判定口径" :span="2">{{ current.judgementSummary ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="已完成状态" :span="2">
-						<template v-if="current.completedStates.length">
-							<el-tag v-for="s in current.completedStates" :key="s" size="small" style="margin-right: 6px">{{ s }}</el-tag>
-						</template>
-						<template v-else>—</template>
-					</el-descriptions-item>
-					<el-descriptions-item label="去重对象" :span="2">{{ current.dedupIdentitySummary ?? '—' }}</el-descriptions-item>
-				</el-descriptions>
-
-				<!-- B. 运行参数:本页唯一可写区。 -->
-				<el-divider content-position="left">运行参数</el-divider>
-				<el-form label-width="150px" size="small" :disabled="current.definitionMissing">
-					<el-form-item label="轮询间隔(秒)">
-						<el-input-number v-model="form.pollIntervalSeconds"
-							:min="policy?.pollIntervalSecondsMin ?? 60" :max="policy?.pollIntervalSecondsMax ?? 86400" />
-					</el-form-item>
-					<el-form-item label="连续命中建单次数">
-						<el-input-number v-model="form.triggerCountRequired"
-							:min="policy?.triggerCountRequiredMin ?? 1" :max="policy?.triggerCountRequiredMax ?? 10" />
-					</el-form-item>
-					<el-form-item label="连续未命中恢复次数">
-						<el-input-number v-model="form.recoverCountRequired"
-							:min="policy?.recoverCountRequiredMin ?? 1" :max="policy?.recoverCountRequiredMax ?? 10" />
-					</el-form-item>
-					<el-form-item label="宽限分钟">
-						<el-input-number v-model="form.graceMinutes" :min="policy?.graceMinutesMin ?? 0" :max="graceMax" />
-					</el-form-item>
-					<el-form-item label="严重程度">
-						<el-select v-model="form.severity" style="width: 180px">
-							<el-option v-for="s in (policy?.allowedSeverities ?? ['FOLLOW', 'SERIOUS'])"
-								:key="s" :label="severityLabel(s)" :value="s" />
-						</el-select>
-					</el-form-item>
-					<el-form-item v-if="policy?.allowsDepartmentDefaults !== false" label="默认发生部门">
-						<!-- 刻意不 clearable:后端当前不支持清空,给一个点了没用的清除按钮比不给更糟。 -->
-						<el-select v-model="form.defaultOccurrenceDeptId" filterable placeholder="未配置" style="width: 260px">
-							<el-option v-for="d in departments" :key="d.value" :label="d.label" :value="d.value" />
-						</el-select>
-					</el-form-item>
-					<el-form-item v-if="policy?.allowsDepartmentDefaults !== false" label="默认责任部门">
-						<el-select v-model="form.defaultResponsibleDeptId" filterable placeholder="未配置" style="width: 260px">
-							<el-option v-for="d in departments" :key="d.value" :label="d.label" :value="d.value" />
-						</el-select>
-					</el-form-item>
-					<el-form-item v-if="currentMissingDepartments.length > 0">
-						<el-alert type="warning" show-icon :closable="false"
-							title="启用前必须配置默认发生部门和默认责任部门。"
-							:description="`当前缺少:${currentMissingDepartments.join('、')}。该规则的数据集不提供部门信息,未配置将导致启用被拒绝。`" />
-					</el-form-item>
-					<el-form-item>
-						<el-button type="primary" :loading="saving" @click="saveParameters">保存参数</el-button>
-						<span class="hint">当前版本暂不支持清除已配置部门,如需变更请重新选择。</span>
-					</el-form-item>
-				</el-form>
-
-				<!-- C. 预演:只读模拟,不产生 Detection / Exception / Timeline。 -->
-				<el-divider content-position="left">预演</el-divider>
-				<div class="preview-bar">
-					<el-button :loading="previewing" :disabled="current.definitionMissing" @click="runPreview">预演</el-button>
-					<span class="hint">只读模拟:按当前参数试算会命中什么,不会建立任何异常单。</span>
-				</div>
-				<el-descriptions v-if="preview" :column="2" border size="small" style="margin-top: 12px">
-					<el-descriptions-item label="候选行数">{{ preview.candidateRows }}</el-descriptions-item>
-					<el-descriptions-item label="命中数">{{ preview.hitCount }}</el-descriptions-item>
-					<el-descriptions-item label="将新建异常">{{ preview.wouldCreateCount }}</el-descriptions-item>
-					<el-descriptions-item label="将刷新既有异常">{{ preview.wouldMatchExistingCount }}</el-descriptions-item>
-					<el-descriptions-item label="未命中行数" :span="2">{{ preview.noHitRows }}</el-descriptions-item>
-				</el-descriptions>
-				<el-table v-if="preview && preview.hits.length" :data="preview.hits.slice(0, 20)" size="small" border
-					style="margin-top: 12px">
-					<el-table-column label="业务对象" prop="sourceObjectId" min-width="160" />
-					<el-table-column label="关联单号" prop="relatedObjectCode" min-width="140" />
-					<el-table-column label="名称" prop="relatedObjectName" min-width="140" show-overflow-tooltip />
-					<el-table-column label="处理方式" width="120">
-						<template #default="{ row }">{{ row.existingActiveExceptionId ? '刷新既有' : '新建' }}</template>
-					</el-table-column>
-				</el-table>
-				<div v-if="preview && preview.hits.length > 20" class="hint" style="margin-top: 6px">
-					仅展示前 20 条,共 {{ preview.hits.length }} 条命中。
-				</div>
+				<el-tabs v-model="activeTab">
+					<!-- TAB 1 · 规则信息:由代码版本定义,业务用户不可改。 -->
+					<el-tab-pane label="规则信息" name="info">
+						<!-- A. 基础定义:由系统版本定义,全只读。
+						     刻意用 descriptions 而非 disabled input——后者会让人以为"暂时不能编辑"。 -->
+						<el-divider content-position="left">基础定义(由系统版本定义)</el-divider>
+						<el-descriptions :column="2" border size="small">
+							<el-descriptions-item label="规则名称">{{ current.displayName ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="规则编码">{{ current.ruleCode }}</el-descriptions-item>
+							<el-descriptions-item label="业务说明" :span="2">{{ current.description ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="数据集">{{ current.datasetDisplayName ?? current.datasetCode ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="业务对象">{{ current.sourceObjectType ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="规则类型">{{ current.ruleType ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="报警机制">{{ current.ruleMechanism ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="场景">{{ current.sceneCode ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="阶段">{{ current.stageCode ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="订单流程">{{ current.orderFlowCode ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="异常类型">{{ current.exceptionTypeCode ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="判定口径" :span="2">{{ current.judgementSummary ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="已完成状态" :span="2">
+								<template v-if="current.completedStates.length">
+									<el-tag v-for="s in current.completedStates" :key="s" size="small" style="margin-right: 6px">{{ s }}</el-tag>
+								</template>
+								<template v-else>—</template>
+							</el-descriptions-item>
+							<el-descriptions-item label="去重对象" :span="2">{{ current.dedupIdentitySummary ?? '—' }}</el-descriptions-item>
+						</el-descriptions>
+
+					</el-tab-pane>
+
+					<!-- TAB 2 · 运行设置:本弹窗唯一可写区 + 预演 + 运行状态。 -->
+					<el-tab-pane label="运行设置" name="runtime">
+						<!-- B. 运行参数:本页唯一可写区。 -->
+						<el-divider content-position="left">运行参数</el-divider>
+						<el-form label-width="150px" size="small" :disabled="current.definitionMissing">
+							<el-form-item label="轮询间隔(秒)">
+								<el-input-number v-model="form.pollIntervalSeconds"
+									:min="policy?.pollIntervalSecondsMin ?? 60" :max="policy?.pollIntervalSecondsMax ?? 86400" />
+							</el-form-item>
+							<el-form-item label="连续命中建单次数">
+								<el-input-number v-model="form.triggerCountRequired"
+									:min="policy?.triggerCountRequiredMin ?? 1" :max="policy?.triggerCountRequiredMax ?? 10" />
+							</el-form-item>
+							<el-form-item label="连续未命中恢复次数">
+								<el-input-number v-model="form.recoverCountRequired"
+									:min="policy?.recoverCountRequiredMin ?? 1" :max="policy?.recoverCountRequiredMax ?? 10" />
+							</el-form-item>
+							<el-form-item label="宽限分钟">
+								<el-input-number v-model="form.graceMinutes" :min="policy?.graceMinutesMin ?? 0" :max="graceMax" />
+							</el-form-item>
+							<el-form-item label="严重程度">
+								<el-select v-model="form.severity" style="width: 180px">
+									<el-option v-for="s in (policy?.allowedSeverities ?? ['FOLLOW', 'SERIOUS'])"
+										:key="s" :label="severityLabel(s)" :value="s" />
+								</el-select>
+							</el-form-item>
+							<el-form-item v-if="policy?.allowsDepartmentDefaults !== false" label="默认发生部门">
+								<!-- 刻意不 clearable:后端当前不支持清空,给一个点了没用的清除按钮比不给更糟。 -->
+								<el-select v-model="form.defaultOccurrenceDeptId" filterable placeholder="未配置" style="width: 260px">
+									<el-option v-for="d in departments" :key="d.value" :label="d.label" :value="d.value" />
+								</el-select>
+							</el-form-item>
+							<el-form-item v-if="policy?.allowsDepartmentDefaults !== false" label="默认责任部门">
+								<el-select v-model="form.defaultResponsibleDeptId" filterable placeholder="未配置" style="width: 260px">
+									<el-option v-for="d in departments" :key="d.value" :label="d.label" :value="d.value" />
+								</el-select>
+							</el-form-item>
+							<el-form-item v-if="currentMissingDepartments.length > 0">
+								<el-alert type="warning" show-icon :closable="false"
+									title="启用前必须配置默认发生部门和默认责任部门。"
+									:description="`当前缺少:${currentMissingDepartments.join('、')}。该规则的数据集不提供部门信息,未配置将导致启用被拒绝。`" />
+							</el-form-item>
+							<el-form-item>
+								<el-button type="primary" :loading="saving" @click="saveParameters">保存参数</el-button>
+								<span class="hint">当前版本暂不支持清除已配置部门,如需变更请重新选择。</span>
+							</el-form-item>
+						</el-form>
+
+						<!-- C. 预演:只读模拟,不产生 Detection / Exception / Timeline。 -->
+						<el-divider content-position="left">预演</el-divider>
+						<div class="preview-bar">
+							<el-button :loading="previewing" :disabled="current.definitionMissing" @click="runPreview">预演</el-button>
+							<span class="hint">只读模拟:按当前参数试算会命中什么,不会建立任何异常单。</span>
+						</div>
+						<el-descriptions v-if="preview" :column="2" border size="small" style="margin-top: 12px">
+							<el-descriptions-item label="候选行数">{{ preview.candidateRows }}</el-descriptions-item>
+							<el-descriptions-item label="命中数">{{ preview.hitCount }}</el-descriptions-item>
+							<el-descriptions-item label="将新建异常">{{ preview.wouldCreateCount }}</el-descriptions-item>
+							<el-descriptions-item label="将刷新既有异常">{{ preview.wouldMatchExistingCount }}</el-descriptions-item>
+							<el-descriptions-item label="未命中行数" :span="2">{{ preview.noHitRows }}</el-descriptions-item>
+						</el-descriptions>
+						<el-table v-if="preview && preview.hits.length" :data="preview.hits.slice(0, 20)" size="small" border
+							style="margin-top: 12px">
+							<el-table-column label="业务对象" prop="sourceObjectId" min-width="160" />
+							<el-table-column label="关联单号" prop="relatedObjectCode" min-width="140" />
+							<el-table-column label="名称" prop="relatedObjectName" min-width="140" show-overflow-tooltip />
+							<el-table-column label="处理方式" width="120">
+								<template #default="{ row }">{{ row.existingActiveExceptionId ? '刷新既有' : '新建' }}</template>
+							</el-table-column>
+						</el-table>
+						<div v-if="preview && preview.hits.length > 20" class="hint" style="margin-top: 6px">
+							仅展示前 20 条,共 {{ preview.hits.length }} 条命中。
+						</div>
 
-				<!-- D. 运行状态:系统写,页面只读。不展示调度租约等内部细节。 -->
-				<el-divider content-position="left">运行状态</el-divider>
-				<el-descriptions :column="2" border size="small">
-					<el-descriptions-item label="当前状态">
-						<el-tag size="small" :type="STATE_META[ruleState(current)].type">{{ STATE_META[ruleState(current)].label }}</el-tag>
-					</el-descriptions-item>
-					<el-descriptions-item label="下次运行">{{ current.nextRunAt ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="最近运行">{{ current.lastRunAt ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="最近结果">{{ lastResultText(current) }}</el-descriptions-item>
-					<el-descriptions-item label="耗时">{{ current.lastDurationMs != null ? current.lastDurationMs + ' ms' : '—' }}</el-descriptions-item>
-					<el-descriptions-item label="连续失败">{{ current.consecutiveFailureCount }}</el-descriptions-item>
-					<el-descriptions-item label="暂停至">{{ current.pausedUntil ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="暂停原因">{{ current.pauseReason ?? '—' }}</el-descriptions-item>
-					<el-descriptions-item label="错误摘要" :span="2">{{ current.lastError ?? '—' }}</el-descriptions-item>
-				</el-descriptions>
-				<div class="runtime-actions">
-					<el-button size="small" type="success" :disabled="current.definitionMissing || !current.enabled"
-						@click="runNow(current)">立即执行</el-button>
-					<span class="hint">「立即执行」会真实运行并可能建立异常单,与上方「预演」不同。</span>
-				</div>
+						<!-- D. 运行状态:系统写,页面只读。不展示调度租约等内部细节。 -->
+						<el-divider content-position="left">运行状态</el-divider>
+						<el-descriptions :column="2" border size="small">
+							<el-descriptions-item label="当前状态">
+								<el-tag size="small" :type="STATE_META[ruleState(current)].type">{{ STATE_META[ruleState(current)].label }}</el-tag>
+							</el-descriptions-item>
+							<el-descriptions-item label="下次运行">{{ current.nextRunAt ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="最近运行">{{ current.lastRunAt ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="最近结果">{{ lastResultText(current) }}</el-descriptions-item>
+							<el-descriptions-item label="耗时">{{ current.lastDurationMs != null ? current.lastDurationMs + ' ms' : '—' }}</el-descriptions-item>
+							<el-descriptions-item label="连续失败">{{ current.consecutiveFailureCount }}</el-descriptions-item>
+							<el-descriptions-item label="暂停至">{{ current.pausedUntil ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="暂停原因">{{ current.pauseReason ?? '—' }}</el-descriptions-item>
+							<el-descriptions-item label="错误摘要" :span="2">{{ current.lastError ?? '—' }}</el-descriptions-item>
+						</el-descriptions>
+						<div class="runtime-actions">
+							<el-button size="small" type="success" :disabled="current.definitionMissing || !current.enabled"
+								@click="runNow(current)">立即执行</el-button>
+							<span class="hint">「立即执行」会真实运行并可能建立异常单,与上方「预演」不同。</span>
+						</div>
+					</el-tab-pane>
+
+					<!-- TAB 3 · 处理与审核:责任维度(可写)+ 审核来源(只读事实)。 -->
+					<el-tab-pane label="处理与审核" name="people">
+						<!-- S8-HANDLER-POOL-1:责任维度。谁负责这条规则,与「异常操作权限」的角色维度正交。 -->
+						<el-card 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>
+							</template>
+							<el-alert v-if="handlerPoolLoaded && handlerPool.length === 0" type="warning"
+								:closable="false" show-icon style="margin-bottom: 10px">
+								<template #title>该规则尚未配置处理账号</template>
+								<div style="line-height: 1.9">
+									没有处理账号的规则<strong>无法启用</strong>:它会源源不断建出异常、却没有人能认领,
+									而调度器每一轮都报成功。
+								</div>
+							</el-alert>
+							<el-alert v-if="handlerPoolInvalid.length" type="error" :closable="false" show-icon
+								style="margin-bottom: 10px">
+								<template #title>有 {{ handlerPoolInvalid.length }} 个已配置的账号当前不可用</template>
+								<div style="line-height: 1.9">
+									这些账号已停用或不在当前租户,<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-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">
+								池成员才能认领 / 被转派该规则产生的异常。能否认领还同时取决于账号是否拥有「认领」动作权限,
+								以及单据当前状态。
+							</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-tab-pane>
+
+					<!--
+						TAB 4 · 通知与升级:P1-E 的 UI 容器。
+						刻意不铺 L1_OPERATOR / L2_MANAGER / L3_DIRECTOR ——
+						那三档在库里只有 L1 真正被用过,且「主管 / 总监」没有对应业务实体。
+					-->
+					<el-tab-pane label="通知与升级" name="notify">
+						<el-empty description="通知收件人配置将在下一批(P1-E)接入">
+							<div style="color: #909399; font-size: 12px; line-height: 1.9; max-width: 560px">
+								届时可按事件配置收件人:异常产生 → 处理人员池;认领成功 → 当前处理人;<br />
+								提交复核 → 上方「审核来源」解析出的实际审核人;超时升级 → 指定账号。
+							</div>
+						</el-empty>
+					</el-tab-pane>
+				</el-tabs>
 			</template>
-		</el-drawer>
+		</el-dialog>
 	</AidopDemoShell>
 </template>
 

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.498</AssemblyVersion>
-    <FileVersion>1.0.498</FileVersion>
-    <Version>1.0.498</Version>
+    <AssemblyVersion>1.0.499</AssemblyVersion>
+    <FileVersion>1.0.499</FileVersion>
+    <Version>1.0.499</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 202 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ReviewSourceTests.cs

@@ -0,0 +1,202 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-RULE-4TAB-1:审核来源<b>只读</b>呈现。
+///
+/// <para><b>本批刻意不做的事</b>(产品决策已锁定):不新建 Rule 级 Reviewer Pool、
+/// 不改 ApproverType、不动审批流节点、不改 owner/escalate 业务语义。
+/// 这些"不做"必须被测试守住 —— 否则下一个人很容易顺手"补全"一个 reviewer_user_ids,
+/// 于是同一个问题又出现两套 authority。</para>
+///
+/// <para><b>为什么要做这个只读视图</b>:规则页此前把审核人显示为「主管」,
+/// 而系统里根本不存在组织主管关系(<c>SysOrg.DirectorId</c> 与
+/// <c>SysUser.ManagerUserId</c> 实测填充率均为 0)。管理员据此以为审核链是通的,
+/// 实际上 UAT 租户的复核流程审批人在本租户下解析为 0 人、
+/// 升级流程三个节点全部指向默认租户的一个演示账号。</para>
+/// </summary>
+public class S8ReviewSourceTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string CodeOnly(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // 节点解析:宽松读取,认不出就说不知道,绝不猜
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>真实流程定义(UAT 租户 EXCEPTION_CLOSURE 的实际 JSON 形态)能被解出。</summary>
+    [Fact]
+    public void ParseApproverNodes_ReadsRealFlowShape()
+    {
+        const string json = """
+        {"nodes":[{"nodeKey":"n1","nodeName":"负责人确认","approverType":2,"approverIds":"1300000000101,1300000000888"}]}
+        """;
+        var nodes = S8ReviewSourceService.ParseApproverNodes(json);
+        Assert.Single(nodes);
+        Assert.Equal("负责人确认", nodes[0].NodeName);
+        Assert.Equal(2, nodes[0].Type);                    // ApproverTypeEnum.Role
+        Assert.Equal("1300000000101,1300000000888", nodes[0].Ids);
+    }
+
+    /// <summary>嵌套结构同样能找到节点(流程定义的外层结构由 ApprovalFlow 插件决定,不该写死)。</summary>
+    [Fact]
+    public void ParseApproverNodes_WalksNestedStructures()
+    {
+        const string json = """
+        {"flow":{"children":[{"branches":[[{"nodeName":"厂级审批","approverType":1,"approverIds":"789819678195781"}]]}]}}
+        """;
+        var nodes = S8ReviewSourceService.ParseApproverNodes(json);
+        Assert.Single(nodes);
+        Assert.Equal("厂级审批", nodes[0].NodeName);
+        Assert.Equal(1, nodes[0].Type);                    // SpecificUser
+    }
+
+    /// <summary>
+    /// 空 / 无审批人的定义解出 0 个节点,<b>不抛异常</b>。
+    /// 上层会显示「本租户没有该流程」,而不是崩在配置页上。
+    /// </summary>
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("   ")]
+    [InlineData("""{"nodes":[]}""")]
+    [InlineData("""{"nodes":[{"nodeName":"仅抄送"}]}""")]
+    public void ParseApproverNodes_HandlesEmptyGracefully(string? json)
+    {
+        Assert.Empty(S8ReviewSourceService.ParseApproverNodes(json));
+    }
+
+    /// <summary>认不出的 approverType 一律跳过,不猜成某个默认值。</summary>
+    [Fact]
+    public void ParseApproverNodes_SkipsUnrecognizedApproverType()
+    {
+        const string json = """
+        {"nodes":[{"nodeName":"怪节点","approverType":{"weird":true},"approverIds":"x"},
+                  {"nodeName":"正常","approverType":2,"approverIds":"ROLE_X"}]}
+        """;
+        var nodes = S8ReviewSourceService.ParseApproverNodes(json);
+        Assert.Single(nodes);
+        Assert.Equal("正常", nodes[0].NodeName);
+    }
+
+    /// <summary>字符串形式的 approverType 也接受(不同版本的流程设计器写法不一)。</summary>
+    [Fact]
+    public void ParseApproverNodes_AcceptsStringApproverType()
+    {
+        const string json = """{"nodes":[{"nodeName":"n","approverType":"Role","approverIds":"ROLE_A"}]}""";
+        var nodes = S8ReviewSourceService.ParseApproverNodes(json);
+        Assert.Single(nodes);
+        Assert.Equal(2, nodes[0].Type);
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // 只读边界:本批锁定的"不做"必须被守住
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 审核来源服务<b>绝不写库</b>,也不新建 reviewer 概念。
+    ///
+    /// <para>产品决策:不新建 Rule 级 Reviewer Pool、不改 ApproverType、不动流程节点。
+    /// 这条守卫防的是下一个人"顺手补全"—— 一旦出现 reviewer_user_ids,
+    /// 审核人就有了两个 authority,而现有审批流那套还在跑。</para>
+    /// </summary>
+    [Fact]
+    public void ReviewSource_IsStrictlyReadOnly()
+    {
+        var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
+
+        foreach (var write in new[] { "AsInsertable", "AsUpdateable", "AsDeleteable", "ExecuteCommandAsync", "InsertAsync", "UpdateAsync" })
+            Assert.DoesNotContain(write, code);
+
+        foreach (var invented in new[] { "ReviewerPool", "reviewer_user_ids", "ReviewerUserIds", "IS8ReviewerPool" })
+            Assert.DoesNotContain(invented, code);
+    }
+
+    /// <summary>
+    /// 「主管」这类没有真实实体支持的文案不得出现在审核展示里。
+    ///
+    /// <para>系统里 <c>SysOrg.DirectorId</c> 与 <c>SysUser.ManagerUserId</c> 填充率均为 0,
+    /// 显示「主管」等于告诉管理员一个不存在的东西在负责审核。</para>
+    /// </summary>
+    [Fact]
+    public void ReviewSource_DoesNotShowPhantomSupervisor()
+    {
+        var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
+        Assert.DoesNotContain("主管", code);
+        Assert.DoesNotContain("总监", code);
+
+        var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
+        // 页面里只允许在"说明为什么不用这个词"的注释中出现,可执行模板不得渲染它
+        var template = page[page.IndexOf("<template>", StringComparison.Ordinal)..];
+        var templateNoComments = System.Text.RegularExpressions.Regex.Replace(
+            template, "<!--.*?-->", string.Empty,
+            System.Text.RegularExpressions.RegexOptions.Singleline);
+        Assert.DoesNotContain("L2_MANAGER", templateNoComments);
+        Assert.DoesNotContain("L3_DIRECTOR", templateNoComments);
+    }
+
+    /// <summary>
+    /// 解析不出人 / 跨租户引用<b>必须显式暴露</b>,不能静默显示成一片空白。
+    ///
+    /// <para>这正是这个视图存在的意义:让"审核链其实是断的"这件事在页面上看得见。</para>
+    /// </summary>
+    [Fact]
+    public void ReviewSource_SurfacesBrokenApproverChains()
+    {
+        var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
+        Assert.Contains("ResolvesToNobody", code);
+        Assert.Contains("CrossTenantRefs", code);
+        Assert.Contains("解析不出任何审批人", code);
+        Assert.Contains("属于其它租户", code);
+        // 解析失败要说"无法确定",不能装作"没有审批人"
+        Assert.Contains("无法解析", code);
+    }
+
+    /// <summary>
+    /// 复核环节必须说清楚:真正决定审核人的是提交复核时手选的账号,
+    /// 审批流节点只同步状态。
+    ///
+    /// <para>不说清楚的话,管理员会去改流程配置然后发现毫无效果。</para>
+    /// </summary>
+    [Fact]
+    public void ReviewSource_ExplainsVerifierIsChosenAtSubmitTime()
+    {
+        var code = CodeOnly("Service/S8/S8ReviewSourceService.cs");
+        Assert.Contains("verifier_user_id", code);
+        Assert.Contains("不决定", code);
+    }
+
+    /// <summary>
+    /// 四页签弹窗<b>不包含</b>「异常操作权限」——它是 S8 全局配置,
+    /// 放进规则弹窗会让管理员以为改的是这条规则的权限,实际改的是全租户。
+    /// </summary>
+    [Fact]
+    public void RuleDialog_DoesNotEmbedGlobalActionPermission()
+    {
+        var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
+            PluginRoot, "../../../Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue")));
+        Assert.DoesNotContain("actionPermissions", page);
+        Assert.DoesNotContain("action-permissions", page);
+
+        // 四个页签齐全
+        foreach (var tab in new[] { "规则信息", "运行设置", "处理与审核", "通知与升级" })
+            Assert.Contains($"label=\"{tab}\"", page);
+        Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(page, "<el-tab-pane ").Count);
+    }
+}

+ 31 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -231,6 +231,37 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// S8-RULE-4TAB-1:<b>只读</b>返回「这条规则的异常,审核时到底由谁审」。
+    ///
+    /// <para>本批不新建 Rule 级 Reviewer Pool、不改 ApproverType、不动流程节点 ——
+    /// 只把当前事实如实呈现,包括跨租户引用与"解析为 0 人"这类不好看的事实。
+    /// 此前页面把审核人显示为「主管」,而系统里根本不存在组织主管关系。</para>
+    /// </summary>
+    [HttpGet("{id:long}/review-source")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
+    public async Task<IActionResult> ReviewSourceAsync(long id,
+        [FromServices] S8ReviewSourceService reviewSource,
+        [FromServices] SqlSugarRepository<Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule> ruleRep)
+    {
+        try
+        {
+            var scope = await _scope.ResolveAsync();
+            var row = await ruleRep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == id && x.TenantId == scope.TenantId)
+                .Select(x => new { x.SceneCode })
+                .FirstAsync()
+                ?? throw new S8BizException("规则不存在");
+
+            return Ok(new
+            {
+                stages = await reviewSource.DescribeAsync(scope.TenantId),
+                escalateRoles = await reviewSource.DescribeEscalateRolesAsync(scope.TenantId, row.SceneCode),
+            });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
     [HttpPost("{id:long}/enable")]
     [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> EnableAsync(long id)

+ 299 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ReviewSourceService.cs

@@ -0,0 +1,299 @@
+using System.Text.Json;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.ApprovalFlow;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>审批流节点上配置的一个审批人来源(原样呈现,不做任何"修正")。</summary>
+public sealed class S8ReviewApproverNodeDto
+{
+    public string NodeName { get; set; } = string.Empty;
+    /// <summary>原始 approverType(SpecificUser / Role / Department / Initiator / DepartmentLeader)。</summary>
+    public string ApproverType { get; set; } = string.Empty;
+    /// <summary>原始 approverIds,逐字回显。</summary>
+    public string ApproverIds { get; set; } = string.Empty;
+    /// <summary>在<b>当前租户</b>下实际能解析出的账号(展示名)。</summary>
+    public List<string> ResolvedUsers { get; set; } = new();
+    /// <summary>解析结果为 0 人 —— 该节点在本租户下没有任何人可审批。</summary>
+    public bool ResolvesToNobody => ResolvedUsers.Count == 0;
+    /// <summary>配置里引用了不属于本租户的账号 / 角色。</summary>
+    public List<string> CrossTenantRefs { get; set; } = new();
+}
+
+/// <summary>一个审核环节的真实来源。</summary>
+public sealed class S8ReviewStageDto
+{
+    /// <summary>环节名(复核 / 升级)。</summary>
+    public string Stage { get; set; } = string.Empty;
+    /// <summary>这一环的审核人<b>实际由什么决定</b>——用业务语言,不用内部术语。</summary>
+    public string DecidedBy { get; set; } = string.Empty;
+    /// <summary>关联的审批流 BizType(没有则为空)。</summary>
+    public string? FlowBizType { get; set; }
+    public string? FlowName { get; set; }
+    public List<S8ReviewApproverNodeDto> Nodes { get; set; } = new();
+    /// <summary>需要管理员注意的问题(跨租户引用 / 解析为 0 人 / 未配置流程)。</summary>
+    public List<string> Warnings { get; set; } = new();
+}
+
+/// <summary>
+/// S8-RULE-4TAB-1:<b>只读</b>解析「这条规则的异常,审核时到底由谁审」。
+///
+/// <para><b>本服务不改变任何审批语义</b>:不写库、不改 ApproverType、不动流程节点、
+/// 不新增 reviewer 配置。它唯一的职责是把<b>当前事实</b>如实呈现给管理员 ——
+/// 包括那些不好看的事实。</para>
+///
+/// <para><b>为什么必须做这件事</b>:规则配置页此前把审核人显示为「主管」,
+/// 而系统里根本不存在组织主管关系(<c>SysOrg.DirectorId</c> 与
+/// <c>SysUser.ManagerUserId</c> 实测填充率均为 0)。管理员据此以为审核链是通的,
+/// 实际上:UAT 租户的复核流程 <c>EXCEPTION_CLOSURE</c> 配的是
+/// <c>approverType=Role, approverIds=1300000000101,1300000000888</c>,
+/// 而这两个角色<b>属于别的租户</b>,在本租户下解析为 0 人;
+/// 升级流程 <c>EXCEPTION_ESCALATION</c> 三个节点全部指向
+/// <c>SpecificUser=789819678195781(Demo02,默认租户)</c>。
+/// 这些都不是本批要改的东西 —— 但管理员必须看得见。</para>
+/// </summary>
+public class S8ReviewSourceService : ITransient
+{
+    private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
+    private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
+    private readonly IS8UserScopeValidator _userScope;
+    private readonly S8RoleResolver _roleResolver;
+    private readonly ILogger<S8ReviewSourceService> _logger;
+
+    /// <summary>复检流程的 BizType(与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c> 一致)。</summary>
+    private const string ClosureBizType = "EXCEPTION_CLOSURE";
+
+    /// <summary>升级流程的 BizType(与 <c>S8TaskFlowService.UpgradeAsync</c> 一致)。</summary>
+    private const string EscalationBizType = "EXCEPTION_ESCALATION";
+
+    public S8ReviewSourceService(
+        SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep,
+        SqlSugarRepository<AdoS8ExceptionType> typeRep,
+        IS8UserScopeValidator userScope,
+        S8RoleResolver roleResolver,
+        ILogger<S8ReviewSourceService> logger)
+    {
+        _flowRep = flowRep;
+        _typeRep = typeRep;
+        _userScope = userScope;
+        _roleResolver = roleResolver;
+        _logger = logger;
+    }
+
+    /// <summary>解析该租户下两个审核环节的真实来源。</summary>
+    public async Task<List<S8ReviewStageDto>> DescribeAsync(long tenantId)
+    {
+        var stages = new List<S8ReviewStageDto>();
+
+        // ── 复核 ──
+        // 真正决定审核人的是 S8 自己的状态机:提交复核时手选一个本租户账号写入
+        // verifier_user_id。EXCEPTION_CLOSURE 流程是"双线合一"的影子,它的审批人配置
+        // 并不决定谁能点"检验通过"。这一点必须说清楚,否则管理员会去改流程配置,
+        // 然后发现毫无效果。
+        var closure = await DescribeFlowAsync(tenantId, ClosureBizType, "复核");
+        closure.DecidedBy = "提交复核时由处理人手选一名本租户账号(写入 verifier_user_id),"
+                          + "只有该账号能执行「检验通过 / 检验退回」。";
+        if (closure.Nodes.Count > 0)
+            closure.Warnings.Add(
+                "下方审批流节点仅用于同步流程状态,**不决定**谁能点检验通过;改它不会改变实际审核人。");
+        stages.Add(closure);
+
+        // ── 升级 ──
+        var escalation = await DescribeFlowAsync(tenantId, EscalationBizType, "升级");
+        escalation.DecidedBy = escalation.Nodes.Count > 0
+            ? "由升级审批流程的节点配置决定。"
+            : "本租户尚未配置升级审批流程,升级将无人可审。";
+        stages.Add(escalation);
+
+        return stages;
+    }
+
+    /// <summary>
+    /// 附带说明:异常类型上的 <c>escalate_role_code</c>。
+    /// <para>它是超时自动升级时用的角色,与上面的流程节点是<b>两套东西</b>;
+    /// 真库实测 owner 与 escalate 角色几乎全部相同(8 组完全一致),
+    /// 也就是"升级"实际上升给了同一批人。这同样只呈现、不修改。</para>
+    /// </summary>
+    public async Task<List<string>> DescribeEscalateRolesAsync(long tenantId, string? sceneCode)
+    {
+        var rows = await _typeRep.AsQueryable().ClearFilter()
+            .Where(x => x.TenantId == tenantId)
+            .WhereIF(!string.IsNullOrWhiteSpace(sceneCode), x => x.SceneCode == sceneCode)
+            .Select(x => new { x.TypeCode, x.OwnerRoleCode, x.EscalateRoleCode })
+            .ToListAsync();
+
+        return rows
+            .Where(r => !string.IsNullOrWhiteSpace(r.EscalateRoleCode))
+            .Select(r => r.OwnerRoleCode == r.EscalateRoleCode
+                ? $"{r.TypeCode}:{r.EscalateRoleCode}(与责任角色相同,升级后仍是同一批人)"
+                : $"{r.TypeCode}:{r.EscalateRoleCode}")
+            .Distinct()
+            .ToList();
+    }
+
+    // ────────────────────────────────────────────────────────────────
+
+    private async Task<S8ReviewStageDto> DescribeFlowAsync(long tenantId, string bizType, string stageName)
+    {
+        var dto = new S8ReviewStageDto { Stage = stageName, FlowBizType = bizType };
+
+        var flow = await _flowRep.AsQueryable().ClearFilter()
+            .Where(x => x.BizType == bizType && x.TenantId == tenantId && !x.IsDelete)
+            .OrderByDescending(x => x.Id)
+            .FirstAsync();
+
+        if (flow == null)
+        {
+            dto.Warnings.Add($"本租户没有 {bizType} 审批流程。");
+            return dto;
+        }
+
+        dto.FlowName = flow.Name;
+
+        List<(string NodeName, int Type, string Ids)> nodes;
+        try
+        {
+            nodes = ParseApproverNodes(flow.FlowJson);
+        }
+        catch (Exception ex)
+        {
+            // 解析失败要说出来,不能装作"这个流程没有审批人"。
+            _logger.LogWarning(ex, "s8_review_source_parse_failed bizType={BizType} tenant={Tenant}", bizType, tenantId);
+            dto.Warnings.Add("流程定义无法解析,无法确定审批人来源。");
+            return dto;
+        }
+
+        foreach (var (nodeName, type, ids) in nodes)
+        {
+            var node = new S8ReviewApproverNodeDto
+            {
+                NodeName = nodeName,
+                ApproverType = ((ApproverTypeEnum)type).ToString(),
+                ApproverIds = ids
+            };
+
+            switch ((ApproverTypeEnum)type)
+            {
+                case ApproverTypeEnum.SpecificUser:
+                    await ResolveSpecificUsersAsync(tenantId, ids, node);
+                    break;
+                case ApproverTypeEnum.Role:
+                    await ResolveRolesAsync(tenantId, ids, node);
+                    break;
+                default:
+                    // Department / Initiator / DepartmentLeader 由流程引擎在运行时按发起人上下文解析,
+                    // 静态解析不出确定人选。**不猜**,如实说明。
+                    node.ResolvedUsers.Add($"(运行时按发起人解析:{node.ApproverType})");
+                    break;
+            }
+
+            if (node.ResolvesToNobody)
+                dto.Warnings.Add($"节点「{nodeName}」在本租户下解析不出任何审批人。");
+            if (node.CrossTenantRefs.Count > 0)
+                dto.Warnings.Add($"节点「{nodeName}」引用了不属于本租户的对象:{string.Join('、', node.CrossTenantRefs)}。");
+
+            dto.Nodes.Add(node);
+        }
+
+        return dto;
+    }
+
+    private async Task ResolveSpecificUsersAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
+    {
+        var tokens = S8RoleResolver.SplitTokens(ids);
+        foreach (var t in tokens)
+        {
+            if (!long.TryParse(t, out var uid)) { node.CrossTenantRefs.Add($"{t}(非法账号 Id)"); continue; }
+
+            var user = await _userScope.FindActiveUserAsync(tenantId, uid);
+            if (user != null) { node.ResolvedUsers.Add($"{user.DisplayName} ({user.Account})"); continue; }
+
+            // 跨租户 / 已停用 —— 这里刻意查出真名并标注,因为这是给<b>管理员排障</b>用的
+            // 只读视图,不是对外接口;不说清楚他无从判断该改哪里。
+            var any = await _flowRep.Context.Queryable<SysUser>().ClearFilter()
+                .Where(u => u.Id == uid).Select(u => new { u.Account, u.TenantId, u.Status }).FirstAsync();
+            node.CrossTenantRefs.Add(any == null
+                ? $"{t}(账号不存在)"
+                : $"{any.Account}({(any.TenantId == tenantId ? "已停用" : "属于其它租户")})");
+        }
+    }
+
+    private async Task ResolveRolesAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
+    {
+        var tokens = S8RoleResolver.SplitTokens(ids);
+        var userIds = await _roleResolver.ResolveUserIdsAsync(tenantId, tokens);
+
+        var users = await _userScope.ValidateUsersAsync(tenantId, userIds);
+        node.ResolvedUsers.AddRange(users.Select(u => $"{u.DisplayName} ({u.Account})"));
+
+        if (node.ResolvedUsers.Count > 0) return;
+
+        // 解析为 0 人时要指出**为什么**:token 是本租户不存在的角色,还是角色没有成员。
+        foreach (var t in tokens)
+        {
+            var byCode = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
+                .Where(r => r.Code == t && r.TenantId == tenantId).AnyAsync();
+            if (byCode) continue;
+
+            if (long.TryParse(t, out var rid))
+            {
+                var role = await _flowRep.Context.Queryable<SysRole>().ClearFilter()
+                    .Where(r => r.Id == rid).Select(r => new { r.Code, r.TenantId }).FirstAsync();
+                node.CrossTenantRefs.Add(role == null
+                    ? $"{t}(角色不存在)"
+                    : $"{role.Code}(属于其它租户)");
+            }
+            else
+            {
+                node.CrossTenantRefs.Add($"{t}(本租户无此角色)");
+            }
+        }
+    }
+
+    /// <summary>
+    /// 从流程定义 JSON 里抽出审批节点。
+    /// <para>结构由 ApprovalFlow 插件定义,本方法只做<b>宽松读取</b>:认不出的结构一律跳过,
+    /// 绝不猜测。读不出来时上层会显示"无法确定审批人来源",而不是"没有审批人"。</para>
+    /// </summary>
+    internal static List<(string NodeName, int Type, string Ids)> ParseApproverNodes(string? flowJson)
+    {
+        var result = new List<(string, int, string)>();
+        if (string.IsNullOrWhiteSpace(flowJson)) return result;
+
+        using var doc = JsonDocument.Parse(flowJson);
+        Walk(doc.RootElement);
+        return result;
+
+        void Walk(JsonElement el)
+        {
+            switch (el.ValueKind)
+            {
+                case JsonValueKind.Object:
+                    if (el.TryGetProperty("approverType", out var at))
+                    {
+                        var name = el.TryGetProperty("nodeName", out var nn) && nn.ValueKind == JsonValueKind.String
+                            ? nn.GetString() ?? "审批节点"
+                            : "审批节点";
+                        var type = at.ValueKind switch
+                        {
+                            JsonValueKind.Number when at.TryGetInt32(out var n) => n,
+                            JsonValueKind.String when Enum.TryParse<ApproverTypeEnum>(at.GetString(), true, out var e) => (int)e,
+                            _ => -1
+                        };
+                        var ids = el.TryGetProperty("approverIds", out var ai) && ai.ValueKind == JsonValueKind.String
+                            ? ai.GetString() ?? string.Empty
+                            : string.Empty;
+                        if (type >= 0) result.Add((name, type, ids));
+                    }
+                    foreach (var p in el.EnumerateObject()) Walk(p.Value);
+                    break;
+                case JsonValueKind.Array:
+                    foreach (var item in el.EnumerateArray()) Walk(item);
+                    break;
+            }
+        }
+    }
+}