Просмотр исходного кода

feat: complete s8 configurable monitoring setup

YY968XX 1 месяц назад
Родитель
Сommit
81ebc0efa6
32 измененных файлов с 2813 добавлено и 91 удалено
  1. 14 2
      Web/src/router/backEnd.ts
  2. 53 0
      Web/src/utils/aidopMenuDisplay.ts
  3. 64 0
      Web/src/views/aidop/s8/api/s8ConfigApi.spec.ts
  4. 70 0
      Web/src/views/aidop/s8/api/s8ConfigApi.ts
  5. 30 0
      Web/src/views/aidop/s8/api/s8DashboardApi.ts
  6. 23 16
      Web/src/views/aidop/s8/api/s8DeliveryMonitoringApi.ts
  7. 186 0
      Web/src/views/aidop/s8/api/s8MonitoringAnomalyApi.spec.ts
  8. 79 0
      Web/src/views/aidop/s8/api/s8MonitoringCellApi.ts
  9. 22 15
      Web/src/views/aidop/s8/api/s8ProductionMonitoringApi.ts
  10. 46 19
      Web/src/views/aidop/s8/api/s8SupplyMonitoringApi.ts
  11. 11 1
      Web/src/views/aidop/s8/components/config/S8CrudConfigPage.vue
  12. 2 0
      Web/src/views/aidop/s8/config/S8ConfigHubPage.vue
  13. 169 0
      Web/src/views/aidop/s8/config/S8DashboardCellConfigPage.vue
  14. 97 0
      Web/src/views/aidop/s8/config/S8ExceptionTypeConfigPage.vue
  15. 3 3
      Web/src/views/approvalFlow/component/ApprovalPanel.vue
  16. 4 1
      Web/src/views/approvalFlow/component/editDialog.vue
  17. 778 0
      doc/plans/2026-04-18-s8-verification-loop.md
  18. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDashboardCellsController.cs
  19. 57 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigExceptionTypesController.cs
  20. 17 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DashboardController.cs
  21. 55 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8CellDataDto.cs
  22. 73 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DashboardCellConfig.cs
  23. 76 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionType.cs
  24. 172 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8DashboardCellConfigSeedData.cs
  25. 63 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8ExceptionTypeSeedData.cs
  26. 37 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8RolePermissionSeedData.cs
  27. 40 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8SysRoleSeedData.cs
  28. 2 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs
  29. 115 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardCellConfigService.cs
  30. 190 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardCellDataService.cs
  31. 87 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionTypeService.cs
  32. 139 33
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

+ 14 - 2
Web/src/router/backEnd.ts

@@ -251,9 +251,21 @@ export function dynamicImport(dynamicViewsModules: Record<string, Function>, com
 		if (layoutKey) return dynamicViewsModules[layoutKey];
 	}
 	const keys = Object.keys(dynamicViewsModules);
+	const normalizePath = (value: string) =>
+		value
+			.replace(/\\/g, '/')
+			.replace(/^\.\.\/views/, '')
+			.replace(/^\.\.\/layout/, '/layout')
+			.replace(/\.(vue|tsx)$/, '');
+	const normalizedComponent = compTrim.replace(/\\/g, '/').replace(/\.(vue|tsx)$/, '');
 	const matchKeys = keys.filter((key) => {
-		const k = key.replace(/..\/views|../, '');
-		return k.startsWith(`${component}`) || k.startsWith(`/${component}`);
+		const normalizedKey = normalizePath(key);
+		return (
+			normalizedKey === normalizedComponent ||
+			normalizedKey === `/${normalizedComponent.replace(/^\/+/, '')}` ||
+			normalizedKey.startsWith(`${normalizedComponent}/`) ||
+			normalizedKey.startsWith(`/${normalizedComponent.replace(/^\/+/, '')}/`)
+		);
 	});
 	if (matchKeys?.length === 1) {
 		const matchKey = matchKeys[0];

+ 53 - 0
Web/src/utils/aidopMenuDisplay.ts

@@ -51,6 +51,22 @@ const SMART_OPS_CHILDREN: Array<{ path: string; title: string; component: string
 	{ path: '/aidop/smart-ops/s9', title: 'S9运营指标看板', component: '/aidop/kanban/s9', name: 'aidopSmartOpsS9' },
 ];
 
+/** S8 配置中心中的隐藏详情页;库中若缺少授权或未同步新种子,前端补齐后可避免点卡片进入 404 */
+const S8_CONFIG_HIDDEN_ROUTES: Array<{ path: string; title: string; component: string; name: string }> = [
+	{
+		path: '/aidop/s8/config/exception-types',
+		title: '异常类型配置',
+		component: '/aidop/s8/config/S8ExceptionTypeConfigPage',
+		name: 'aidopS8ExceptionTypeConfig',
+	},
+	{
+		path: '/aidop/s8/config/dashboard-cells',
+		title: '大屏卡片配置',
+		component: '/aidop/s8/config/S8DashboardCellConfigPage',
+		name: 'aidopS8DashboardCellConfig',
+	},
+];
+
 function collectPathsUnder(node: AMenu, acc: Set<string>): void {
 	const p = node.path as string | undefined;
 	if (p) acc.add(p);
@@ -128,6 +144,29 @@ function patchAidopCustomMenusIfMissing(routes: AMenu[] | undefined): void {
 	}
 }
 
+/** 为 S8 配置中心补隐藏子路由,兼容数据库未刷新到最新菜单/角色授权时的直达 404 */
+function patchS8ConfigHiddenRoutesIfMissing(routes: AMenu[] | undefined): void {
+	if (!routes?.length) return;
+	const aidopRoot = routes.find((x) => x.path === '/aidop' || x.name === 'aidopRoot');
+	if (!aidopRoot?.children?.length) return;
+
+	const s8Root = aidopRoot.children.find((x: AMenu) => x.path === '/aidop/s8' || x.name === 'aidopDirS8' || x.name === 'aidopDirM09');
+	if (!s8Root) return;
+
+	const existing = new Set<string>();
+	collectPathsUnder(s8Root, existing);
+
+	for (const route of S8_CONFIG_HIDDEN_ROUTES) {
+		upsertMenu(s8Root, {
+			path: route.path,
+			name: route.name,
+			component: route.component,
+			meta: { title: route.title, icon: 'ele-Document', isHide: true },
+		});
+		existing.add(route.path);
+	}
+}
+
 /**
  * 递归覆盖 Ai-DOP 一级目录的 meta.title,供侧栏 / tagsView / 菜单搜索使用。
  * 智慧诊断、智慧运营看板以 sys_menu 为准;若租户/角色未关联到库中菜单,则仅补缺失 path,避免双份。
@@ -145,10 +184,24 @@ function patchSmartOpsRouteComponents(nodes: AMenu[] | undefined): void {
 	}
 }
 
+function patchS8ConfigRouteComponents(nodes: AMenu[] | undefined): void {
+	if (!nodes?.length) return;
+	const byPath = Object.fromEntries(S8_CONFIG_HIDDEN_ROUTES.map((c) => [c.path, c.component])) as Record<string, string>;
+	for (const item of nodes) {
+		const raw = item.path as string | undefined;
+		const p = raw ? (raw.startsWith('/') ? raw : `/${raw}`) : '';
+		const comp = p ? byPath[p] : undefined;
+		if (comp) item.component = comp;
+		if (item.children?.length) patchS8ConfigRouteComponents(item.children as AMenu[]);
+	}
+}
+
 export function patchAidopMenuTitles(routes: any[] | undefined): void {
 	if (!routes?.length) return;
 	patchAidopCustomMenusIfMissing(routes as AMenu[]);
+	patchS8ConfigHiddenRoutesIfMissing(routes as AMenu[]);
 	patchSmartOpsRouteComponents(routes as AMenu[]);
+	patchS8ConfigRouteComponents(routes as AMenu[]);
 	for (const item of routes) {
 		const name = item.name as string | undefined;
 		if (name && AIDOP_DIRECTORY_TITLES[name]) {

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

@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getMock = vi.fn();
+const postMock = vi.fn();
+const putMock = vi.fn();
+const deleteMock = vi.fn();
+
+vi.mock('/@/utils/request', () => ({
+	default: {
+		get: (...args: unknown[]) => getMock(...args),
+		post: (...args: unknown[]) => postMock(...args),
+		put: (...args: unknown[]) => putMock(...args),
+		delete: (...args: unknown[]) => deleteMock(...args),
+		patch: vi.fn(),
+	},
+}));
+
+import { s8ConfigApi } from './s8ConfigApi';
+
+describe('s8ConfigApi typed wrappers', () => {
+	beforeEach(() => {
+		getMock.mockReset();
+		postMock.mockReset();
+		putMock.mockReset();
+		deleteMock.mockReset();
+	});
+
+	it('exceptionTypes list unwraps payload', async () => {
+		const payload = [{ id: 1, typeCode: 'ORDER_CHANGE' }];
+		getMock.mockResolvedValue({ data: payload });
+
+		const result = await s8ConfigApi.exceptionTypes.list({ tenantId: 1, factoryId: 1, enabledOnly: true });
+
+		expect(getMock).toHaveBeenCalledWith('/api/aidop/s8/config/exception-types', {
+			params: { tenantId: 1, factoryId: 1, enabledOnly: true },
+		});
+		expect(result).toEqual(payload);
+	});
+
+	it('dashboardCells update forwards id and body', async () => {
+		const body = {
+			tenantId: 1,
+			factoryId: 1,
+			pageCode: 'DELIVERY',
+			cellCode: 'DELIVERY_ANOMALY_ORDER_CHANGE',
+			cellTitle: '订单变更',
+			bindingType: 'EXCEPTION_TYPE',
+			exceptionTypeCode: 'ORDER_CHANGE',
+			aggregateScope: null,
+			statMetric: 'OPEN_COUNT',
+			timeWindow: 'LAST_24H',
+			filterExpression: '',
+			deptGroupBy: 'OWNER',
+			enabled: true,
+			sortNo: 100,
+		};
+		putMock.mockResolvedValue({ data: { id: 9, ...body } });
+
+		const result = await s8ConfigApi.dashboardCells.update(9, body);
+
+		expect(putMock).toHaveBeenCalledWith('/api/aidop/s8/config/dashboard-cells/9', body);
+		expect(result).toEqual({ id: 9, ...body });
+	});
+});

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

@@ -4,6 +4,52 @@ function unwrap<T>(res: { data: T }): T {
 	return res.data;
 }
 
+export interface S8ExceptionTypeConfigRow {
+	id: number;
+	tenantId: number;
+	factoryId: number;
+	typeCode: string;
+	typeName: string;
+	domainCode: string;
+	sceneCode: string;
+	severityDefault: string;
+	slaMinutes: number;
+	ownerRoleCode?: string | null;
+	escalateRoleCode?: string | null;
+	statsMode: string;
+	mobileVisible: boolean;
+	icon?: string | null;
+	enabled: boolean;
+	sortNo: number;
+	remark?: string | null;
+	createdAt: string;
+	updatedAt?: string | null;
+}
+
+export type S8ExceptionTypeConfigPayload = Omit<S8ExceptionTypeConfigRow, 'id' | 'createdAt' | 'updatedAt'>;
+
+export interface S8DashboardCellConfigRow {
+	id: number;
+	tenantId: number;
+	factoryId: number;
+	pageCode: 'OVERVIEW' | 'DELIVERY' | 'PRODUCTION' | 'SUPPLY' | string;
+	cellCode: string;
+	cellTitle?: string | null;
+	bindingType: 'EXCEPTION_TYPE' | 'AGGREGATE' | 'CUSTOM' | string;
+	exceptionTypeCode?: string | null;
+	aggregateScope?: string | null;
+	statMetric: 'OPEN_COUNT' | 'FREQUENCY' | 'AVG_DURATION' | 'CLOSE_RATE' | string;
+	timeWindow: 'TODAY' | 'LAST_24H' | 'LAST_7D' | 'LAST_30D' | string;
+	filterExpression?: string | null;
+	deptGroupBy: 'OWNER' | 'OCCUR' | string;
+	enabled: boolean;
+	sortNo: number;
+	createdAt: string;
+	updatedAt?: string | null;
+}
+
+export type S8DashboardCellConfigPayload = Omit<S8DashboardCellConfigRow, 'id' | 'createdAt' | 'updatedAt'>;
+
 export const s8ConfigApi = {
 	list: (endpoint: string, params?: Record<string, unknown>) => service.get(endpoint, { params }).then(unwrap),
 	create: (endpoint: string, body: Record<string, unknown>) => service.post(endpoint, body).then(unwrap),
@@ -17,4 +63,28 @@ export const s8ConfigApi = {
 		service.get('/api/aidop/s8/config/data-sources', { params }).then(unwrap),
 	importRolesFromSys: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.post('/api/aidop/s8/config/roles/import-from-sys', null, { params }).then(unwrap),
+	exceptionTypes: {
+		list: (params?: { tenantId?: number; factoryId?: number; domainCode?: string; enabledOnly?: boolean }) =>
+			service.get<S8ExceptionTypeConfigRow[]>('/api/aidop/s8/config/exception-types', { params }).then(unwrap),
+		detail: (typeCode: string, params?: { tenantId?: number; factoryId?: number }) =>
+			service.get<S8ExceptionTypeConfigRow>(`/api/aidop/s8/config/exception-types/${typeCode}`, { params }).then(unwrap),
+		create: (body: S8ExceptionTypeConfigPayload) =>
+			service.post<S8ExceptionTypeConfigRow>('/api/aidop/s8/config/exception-types', body).then(unwrap),
+		update: (id: number, body: S8ExceptionTypeConfigPayload) =>
+			service.put<S8ExceptionTypeConfigRow>(`/api/aidop/s8/config/exception-types/${id}`, body).then(unwrap),
+		setEnabled: (id: number, enabled: boolean) =>
+			service.put(`/api/aidop/s8/config/exception-types/${id}/enabled`, null, { params: { enabled } }).then(unwrap),
+		remove: (id: number) =>
+			service.delete(`/api/aidop/s8/config/exception-types/${id}`).then(unwrap),
+	},
+	dashboardCells: {
+		list: (params?: { tenantId?: number; factoryId?: number }) =>
+			service.get<S8DashboardCellConfigRow[]>('/api/aidop/s8/config/dashboard-cells', { params }).then(unwrap),
+		create: (body: S8DashboardCellConfigPayload) =>
+			service.post<S8DashboardCellConfigRow>('/api/aidop/s8/config/dashboard-cells', body).then(unwrap),
+		update: (id: number, body: S8DashboardCellConfigPayload) =>
+			service.put<S8DashboardCellConfigRow>(`/api/aidop/s8/config/dashboard-cells/${id}`, body).then(unwrap),
+		remove: (id: number) =>
+			service.delete(`/api/aidop/s8/config/dashboard-cells/${id}`).then(unwrap),
+	},
 };

+ 30 - 0
Web/src/views/aidop/s8/api/s8DashboardApi.ts

@@ -60,6 +60,33 @@ export interface S8QuickException {
 	responsibleDeptId: number;
 }
 
+export interface S8CellBreakdownItem {
+	label: string;
+	value: number;
+	code?: string | null;
+}
+
+export interface S8CellData {
+	cellCode: string;
+	pageCode: string;
+	title?: string | null;
+	bindingType: string;
+	statMetric: 'OPEN_COUNT' | 'FREQUENCY' | 'AVG_DURATION' | 'CLOSE_RATE' | string;
+	timeWindow: string;
+	value: number;
+	breakdown: S8CellBreakdownItem[];
+	message?: string | null;
+}
+
+export interface S8CellDataQuery {
+	tenantId?: number;
+	factoryId?: number;
+	pageCode: string;
+	cellCode: string;
+	orderScope?: string;
+	deptGroupBy?: 'OWNER' | 'OCCUR';
+}
+
 export const s8DashboardApi = {
 	overview: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.get<S8OverviewData>('/api/aidop/s8/dashboard/overview', { params }).then(unwrap),
@@ -80,4 +107,7 @@ export const s8DashboardApi = {
 		service.get<{ dates: string[]; series: { name: string; data: number[] }[] }>(
 			'/api/aidop/s8/dashboard/dim-trends', { params }
 		).then(unwrap),
+
+	cellData: (params: S8CellDataQuery) =>
+		service.get<S8CellData>('/api/aidop/s8/dashboard/cell-data', { params }).then(unwrap),
 };

+ 23 - 16
Web/src/views/aidop/s8/api/s8DeliveryMonitoringApi.ts

@@ -1,9 +1,5 @@
-import service from '/@/utils/request';
 import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary } from './s8MonitoringApi';
-
-function unwrap<T>(res: { data: T }): T {
-	return res.data;
-}
+import { loadConfiguredAnomalyTypes } from './s8MonitoringCellApi';
 
 export interface DeliveryAnomalyType {
 	key: string;
@@ -20,12 +16,26 @@ export interface DeliveryMonitoringData {
 }
 
 const DELIVERY_MODULE_CODES = ['S1', 'S7'];
-
-const DEMO_ANOMALY_TYPES: DeliveryAnomalyType[] = [
-	{ key: 'order-change',    label: '订单变更',   total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'delivery-delay',  label: '交期延迟',   total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'stock-pending',   label: '入库待发',   total: 0, avgProcessHours: 0, closeRate: 0 },
-];
+const DELIVERY_ANOMALY_SPECS = [
+	{
+		key: 'order-change',
+		label: '订单变更',
+		totalCellCode: 'DELIVERY_ANOMALY_ORDER_CHANGE',
+		analysisCellCode: 'DELIVERY_CAT_ORDER_CHANGE',
+	},
+	{
+		key: 'delivery-delay',
+		label: '交期延迟',
+		totalCellCode: 'DELIVERY_ANOMALY_DELIVERY_DELAY',
+		analysisCellCode: 'DELIVERY_CAT_DELIVERY_DELAY',
+	},
+	{
+		key: 'stock-pending',
+		label: '入库待发',
+		totalCellCode: 'DELIVERY_ANOMALY_STOCK_PENDING',
+		analysisCellCode: 'DELIVERY_CAT_STOCK_PENDING',
+	},
+] as const;
 
 export const s8DeliveryMonitoringApi = {
 	/** 获取交付域(S1+S7)的汇总摘要 */
@@ -38,10 +48,7 @@ export const s8DeliveryMonitoringApi = {
 			data.modules.filter((m) => DELIVERY_MODULE_CODES.includes(m.moduleCode)),
 		),
 
-	/** 获取交付异常类型明细(后端未就绪时降级为空数据) */
+	/** 获取交付异常类型明细;接口失败返回空数组(不再回退到示例数据) */
 	anomalyTypes: (): Promise<DeliveryAnomalyType[]> =>
-		service
-			.get<DeliveryAnomalyType[]>('/api/aidop/s8/monitoring/delivery/anomaly-types')
-			.then(unwrap)
-			.catch((): DeliveryAnomalyType[] => DEMO_ANOMALY_TYPES),
+		loadConfiguredAnomalyTypes('DELIVERY', [...DELIVERY_ANOMALY_SPECS]),
 };

+ 186 - 0
Web/src/views/aidop/s8/api/s8MonitoringAnomalyApi.spec.ts

@@ -0,0 +1,186 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getMock = vi.fn();
+
+vi.mock('/@/utils/request', () => ({
+	default: {
+		get: (...args: unknown[]) => getMock(...args),
+		post: vi.fn(),
+		put: vi.fn(),
+		delete: vi.fn(),
+		patch: vi.fn(),
+	},
+}));
+
+import { s8DeliveryMonitoringApi } from './s8DeliveryMonitoringApi';
+import { s8ProductionMonitoringApi } from './s8ProductionMonitoringApi';
+import { s8SupplyMonitoringApi } from './s8SupplyMonitoringApi';
+
+describe('s8 anomaly monitoring api', () => {
+	beforeEach(() => {
+		getMock.mockReset();
+	});
+
+	it('production anomalyTypes unwraps backend payload', async () => {
+		getMock
+			.mockResolvedValueOnce({
+				data: { cellCode: 'PRODUCTION_ANOMALY_EQUIPMENT_FAULT', pageCode: 'PRODUCTION', title: '设备异常', bindingType: 'EXCEPTION_TYPE', statMetric: 'OPEN_COUNT', timeWindow: 'LAST_24H', value: 2, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'PRODUCTION_CAT_EQUIPMENT_FAULT', pageCode: 'PRODUCTION', title: '设备异常多维分析', bindingType: 'EXCEPTION_TYPE', statMetric: 'FREQUENCY', timeWindow: 'LAST_7D', value: 6, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'PRODUCTION_ANOMALY_MATERIAL_FAULT', pageCode: 'PRODUCTION', title: '物料异常', bindingType: 'EXCEPTION_TYPE', statMetric: 'OPEN_COUNT', timeWindow: 'LAST_24H', value: 3, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'PRODUCTION_CAT_MATERIAL_FAULT', pageCode: 'PRODUCTION', title: '物料异常多维分析', bindingType: 'EXCEPTION_TYPE', statMetric: 'AVG_DURATION', timeWindow: 'LAST_7D', value: 12.5, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'PRODUCTION_ANOMALY_QUALITY_FAULT', pageCode: 'PRODUCTION', title: '质量异常', bindingType: 'EXCEPTION_TYPE', statMetric: 'OPEN_COUNT', timeWindow: 'LAST_24H', value: 4, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'PRODUCTION_CAT_QUALITY_FAULT', pageCode: 'PRODUCTION', title: '质量异常多维分析', bindingType: 'EXCEPTION_TYPE', statMetric: 'CLOSE_RATE', timeWindow: 'LAST_7D', value: 75, breakdown: [] },
+			});
+
+		const result = await s8ProductionMonitoringApi.anomalyTypes();
+
+		expect(getMock).toHaveBeenCalledWith('/api/aidop/s8/dashboard/cell-data', {
+			params: { pageCode: 'PRODUCTION', cellCode: 'PRODUCTION_ANOMALY_EQUIPMENT_FAULT' },
+		});
+		expect(result).toEqual([
+			{ key: 'equipment-fault', label: '设备异常', total: 2, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'material-fault', label: '物料异常', total: 3, avgProcessHours: 12.5, closeRate: 0 },
+			{ key: 'quality-fault', label: '质量异常', total: 4, avgProcessHours: 0, closeRate: 75 },
+		]);
+	});
+
+	it('production anomalyTypes returns [] on error', async () => {
+		getMock.mockRejectedValue(new Error('network'));
+		const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+		const result = await s8ProductionMonitoringApi.anomalyTypes();
+
+		expect(result).toEqual([
+			{ key: 'equipment-fault', label: '设备异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'material-fault', label: '物料异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'quality-fault', label: '质量异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+		]);
+		expect(errorSpy).toHaveBeenCalled();
+		errorSpy.mockRestore();
+	});
+
+	it('supply anomalyTypes unwraps backend payload', async () => {
+		const supplyCells = [
+			['SUPPLY_ANOMALY_SUPPLIER_REPLY_DELAY', '供应商回复交期异常', 1],
+			['SUPPLY_CAT_SUPPLIER_REPLY_DELAY', '供应商回复交期异常多维分析', 11],
+			['SUPPLY_ANOMALY_SUPPLIER_SHIP_FAULT', '供应商发货异常', 2],
+			['SUPPLY_CAT_SUPPLIER_SHIP_FAULT', '供应商发货异常多维分析', 12],
+			['SUPPLY_ANOMALY_WAREHOUSE_RECEIPT', '仓库收货异常', 3],
+			['SUPPLY_CAT_WAREHOUSE_RECEIPT', '仓库收货异常多维分析', 13],
+			['SUPPLY_ANOMALY_IQC_INSPECTION', 'IQC 检验异常', 4],
+			['SUPPLY_CAT_IQC_INSPECTION', 'IQC 检验异常多维分析', 14],
+			['SUPPLY_ANOMALY_WAREHOUSE_SHELVING', '仓库上架入库异常', 5],
+			['SUPPLY_CAT_WAREHOUSE_SHELVING', '仓库上架入库异常多维分析', 15],
+			['SUPPLY_ANOMALY_WORK_ORDER_PREPARE', '仓库工单备料异常', 6],
+			['SUPPLY_CAT_WORK_ORDER_PREPARE', '仓库工单备料异常多维分析', 16],
+			['SUPPLY_ANOMALY_WORK_ORDER_ISSUE', '仓库工单发料异常', 7],
+			['SUPPLY_CAT_WORK_ORDER_ISSUE', '仓库工单发料异常多维分析', 17],
+		] as const;
+		supplyCells.forEach(([cellCode, title, value], idx) => {
+			getMock.mockResolvedValueOnce({
+				data: {
+					cellCode,
+					pageCode: 'SUPPLY',
+					title,
+					bindingType: 'EXCEPTION_TYPE',
+					statMetric: idx % 2 === 0 ? 'OPEN_COUNT' : 'FREQUENCY',
+					timeWindow: idx % 2 === 0 ? 'LAST_24H' : 'LAST_7D',
+					value,
+					breakdown: [],
+				},
+			});
+		});
+
+		const result = await s8SupplyMonitoringApi.anomalyTypes();
+
+		expect(getMock).toHaveBeenCalledWith('/api/aidop/s8/dashboard/cell-data', {
+			params: { pageCode: 'SUPPLY', cellCode: 'SUPPLY_ANOMALY_SUPPLIER_REPLY_DELAY' },
+		});
+		expect(result).toEqual([
+			{ key: 'supplier-reply-delay', label: '供应商回复交期异常', total: 1, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'supplier-ship-fault', label: '供应商发货异常', total: 2, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'warehouse-receipt', label: '仓库收货异常', total: 3, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'iqc-inspection', label: 'IQC 检验异常', total: 4, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'warehouse-shelving', label: '仓库上架入库异常', total: 5, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'work-order-prepare', label: '仓库工单备料异常', total: 6, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'work-order-issue', label: '仓库工单发料异常', total: 7, avgProcessHours: 0, closeRate: 0 },
+		]);
+	});
+
+	it('supply anomalyTypes returns [] on error', async () => {
+		getMock.mockRejectedValue(new Error('network'));
+		const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+		const result = await s8SupplyMonitoringApi.anomalyTypes();
+
+		expect(result).toEqual([
+			{ key: 'supplier-reply-delay', label: '供应商回复交期异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'supplier-ship-fault', label: '供应商发货异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'warehouse-receipt', label: '仓库收货异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'iqc-inspection', label: 'IQC检验异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'warehouse-shelving', label: '仓库上架入库异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'work-order-prepare', label: '仓库工单备料异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'work-order-issue', label: '仓库工单发料异常', total: 0, avgProcessHours: 0, closeRate: 0 },
+		]);
+		expect(errorSpy).toHaveBeenCalled();
+		errorSpy.mockRestore();
+	});
+
+	it('delivery anomalyTypes unwraps backend payload', async () => {
+		getMock
+			.mockResolvedValueOnce({
+				data: { cellCode: 'DELIVERY_ANOMALY_ORDER_CHANGE', pageCode: 'DELIVERY', title: '订单变更', bindingType: 'EXCEPTION_TYPE', statMetric: 'OPEN_COUNT', timeWindow: 'LAST_24H', value: 3, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'DELIVERY_CAT_ORDER_CHANGE', pageCode: 'DELIVERY', title: '订单变更多维分析', bindingType: 'EXCEPTION_TYPE', statMetric: 'FREQUENCY', timeWindow: 'LAST_7D', value: 9, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'DELIVERY_ANOMALY_DELIVERY_DELAY', pageCode: 'DELIVERY', title: '交期延迟', bindingType: 'EXCEPTION_TYPE', statMetric: 'OPEN_COUNT', timeWindow: 'LAST_24H', value: 4, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'DELIVERY_CAT_DELIVERY_DELAY', pageCode: 'DELIVERY', title: '交期延迟多维分析', bindingType: 'EXCEPTION_TYPE', statMetric: 'AVG_DURATION', timeWindow: 'LAST_7D', value: 5, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'DELIVERY_ANOMALY_STOCK_PENDING', pageCode: 'DELIVERY', title: '入库待发', bindingType: 'EXCEPTION_TYPE', statMetric: 'OPEN_COUNT', timeWindow: 'LAST_24H', value: 6, breakdown: [] },
+			})
+			.mockResolvedValueOnce({
+				data: { cellCode: 'DELIVERY_CAT_STOCK_PENDING', pageCode: 'DELIVERY', title: '入库待发多维分析', bindingType: 'EXCEPTION_TYPE', statMetric: 'CLOSE_RATE', timeWindow: 'LAST_7D', value: 80, breakdown: [] },
+			});
+
+		const result = await s8DeliveryMonitoringApi.anomalyTypes();
+
+		expect(getMock).toHaveBeenCalledWith('/api/aidop/s8/dashboard/cell-data', {
+			params: { pageCode: 'DELIVERY', cellCode: 'DELIVERY_ANOMALY_ORDER_CHANGE' },
+		});
+		expect(result).toEqual([
+			{ key: 'order-change', label: '订单变更', total: 3, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'delivery-delay', label: '交期延迟', total: 4, avgProcessHours: 5, closeRate: 0 },
+			{ key: 'stock-pending', label: '入库待发', total: 6, avgProcessHours: 0, closeRate: 80 },
+		]);
+	});
+
+	it('delivery anomalyTypes returns [] on error', async () => {
+		getMock.mockRejectedValue(new Error('network'));
+		const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+		const result = await s8DeliveryMonitoringApi.anomalyTypes();
+
+		expect(result).toEqual([
+			{ key: 'order-change', label: '订单变更', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'delivery-delay', label: '交期延迟', total: 0, avgProcessHours: 0, closeRate: 0 },
+			{ key: 'stock-pending', label: '入库待发', total: 0, avgProcessHours: 0, closeRate: 0 },
+		]);
+		expect(errorSpy).toHaveBeenCalled();
+		errorSpy.mockRestore();
+	});
+});

+ 79 - 0
Web/src/views/aidop/s8/api/s8MonitoringCellApi.ts

@@ -0,0 +1,79 @@
+import { s8DashboardApi, type S8CellData } from './s8DashboardApi';
+
+export interface S8ConfiguredAnomalyType {
+	key: string;
+	label: string;
+	total: number;
+	avgProcessHours: number;
+	closeRate: number;
+}
+
+export interface S8AnomalyCellSpec {
+	key: string;
+	label: string;
+	totalCellCode: string;
+	analysisCellCode?: string;
+}
+
+function applyConfiguredMetric(target: S8ConfiguredAnomalyType, cell: S8CellData) {
+	switch (cell.statMetric) {
+		case 'OPEN_COUNT':
+			if (cell.title) target.label = cell.title;
+			target.total = cell.value;
+			return;
+		case 'AVG_DURATION':
+			target.avgProcessHours = cell.value;
+			return;
+		case 'CLOSE_RATE':
+			target.closeRate = cell.value;
+			return;
+		default:
+			return;
+	}
+}
+
+export async function loadConfiguredAnomalyTypes(
+	pageCode: string,
+	specs: S8AnomalyCellSpec[],
+	params?: { tenantId?: number; factoryId?: number },
+): Promise<S8ConfiguredAnomalyType[]> {
+	const result = specs.map<S8ConfiguredAnomalyType>((spec) => ({
+		key: spec.key,
+		label: spec.label,
+		total: 0,
+		avgProcessHours: 0,
+		closeRate: 0,
+	}));
+	const anomalyMap = new Map(result.map((item) => [item.key, item]));
+
+	try {
+		const requests = specs.flatMap((spec) => {
+			const cells = [{ key: spec.key, cellCode: spec.totalCellCode }];
+			if (spec.analysisCellCode) {
+				cells.push({ key: spec.key, cellCode: spec.analysisCellCode });
+			}
+			return cells;
+		});
+
+		const responses = await Promise.all(
+			requests.map(async (request) => ({
+				key: request.key,
+				cell: await s8DashboardApi.cellData({
+					...params,
+					pageCode,
+					cellCode: request.cellCode,
+				}),
+			})),
+		);
+
+		responses.forEach(({ key, cell }) => {
+			const target = anomalyMap.get(key);
+			if (!target) return;
+			applyConfiguredMetric(target, cell);
+		});
+	} catch (err) {
+		console.error(`[s8MonitoringCellApi] ${pageCode} anomaly types load failed`, err);
+	}
+
+	return result;
+}

+ 22 - 15
Web/src/views/aidop/s8/api/s8ProductionMonitoringApi.ts

@@ -1,9 +1,5 @@
-import service from '/@/utils/request';
 import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary } from './s8MonitoringApi';
-
-function unwrap<T>(res: { data: T }): T {
-	return res.data;
-}
+import { loadConfiguredAnomalyTypes } from './s8MonitoringCellApi';
 
 export interface ProductionAnomalyType {
 	key: string;
@@ -20,12 +16,26 @@ export interface ProductionMonitoringData {
 }
 
 const PRODUCTION_MODULE_CODES = ['S2', 'S6'];
-
-const DEMO_ANOMALY_TYPES: ProductionAnomalyType[] = [
-	{ key: 'equipment-fault', label: '设备异常', total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'material-fault',  label: '物料异常', total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'quality-fault',   label: '质量异常', total: 0, avgProcessHours: 0, closeRate: 0 },
-];
+const PRODUCTION_ANOMALY_SPECS = [
+	{
+		key: 'equipment-fault',
+		label: '设备异常',
+		totalCellCode: 'PRODUCTION_ANOMALY_EQUIPMENT_FAULT',
+		analysisCellCode: 'PRODUCTION_CAT_EQUIPMENT_FAULT',
+	},
+	{
+		key: 'material-fault',
+		label: '物料异常',
+		totalCellCode: 'PRODUCTION_ANOMALY_MATERIAL_FAULT',
+		analysisCellCode: 'PRODUCTION_CAT_MATERIAL_FAULT',
+	},
+	{
+		key: 'quality-fault',
+		label: '质量异常',
+		totalCellCode: 'PRODUCTION_ANOMALY_QUALITY_FAULT',
+		analysisCellCode: 'PRODUCTION_CAT_QUALITY_FAULT',
+	},
+] as const;
 
 export const s8ProductionMonitoringApi = {
 	/** 获取生产域(S2+S6)的汇总摘要 */
@@ -40,8 +50,5 @@ export const s8ProductionMonitoringApi = {
 
 	/** 获取生产异常类型明细(后端未就绪时降级为空数据) */
 	anomalyTypes: (): Promise<ProductionAnomalyType[]> =>
-		service
-			.get<ProductionAnomalyType[]>('/api/aidop/s8/monitoring/production/anomaly-types')
-			.then(unwrap)
-			.catch((): ProductionAnomalyType[] => DEMO_ANOMALY_TYPES),
+		loadConfiguredAnomalyTypes('PRODUCTION', [...PRODUCTION_ANOMALY_SPECS]),
 };

+ 46 - 19
Web/src/views/aidop/s8/api/s8SupplyMonitoringApi.ts

@@ -1,9 +1,5 @@
-import service from '/@/utils/request';
 import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary } from './s8MonitoringApi';
-
-function unwrap<T>(res: { data: T }): T {
-	return res.data;
-}
+import { loadConfiguredAnomalyTypes } from './s8MonitoringCellApi';
 
 export interface SupplyAnomalyType {
 	key: string;
@@ -20,16 +16,50 @@ export interface SupplyMonitoringData {
 }
 
 const SUPPLY_MODULE_CODES = ['S3', 'S4', 'S5'];
-
-const DEMO_ANOMALY_TYPES: SupplyAnomalyType[] = [
-	{ key: 'supplier-reply-delay',  label: '供应商回复交期异常', total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'supplier-ship-fault',   label: '供应商发货异常',     total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'warehouse-receipt',     label: '仓库收货异常',       total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'iqc-inspection',        label: 'IQC检验异常',        total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'warehouse-shelving',    label: '仓库上架入库异常',   total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'work-order-prepare',    label: '仓库工单备料异常',   total: 0, avgProcessHours: 0, closeRate: 0 },
-	{ key: 'work-order-issue',      label: '仓库工单发料异常',   total: 0, avgProcessHours: 0, closeRate: 0 },
-];
+const SUPPLY_ANOMALY_SPECS = [
+	{
+		key: 'supplier-reply-delay',
+		label: '供应商回复交期异常',
+		totalCellCode: 'SUPPLY_ANOMALY_SUPPLIER_REPLY_DELAY',
+		analysisCellCode: 'SUPPLY_CAT_SUPPLIER_REPLY_DELAY',
+	},
+	{
+		key: 'supplier-ship-fault',
+		label: '供应商发货异常',
+		totalCellCode: 'SUPPLY_ANOMALY_SUPPLIER_SHIP_FAULT',
+		analysisCellCode: 'SUPPLY_CAT_SUPPLIER_SHIP_FAULT',
+	},
+	{
+		key: 'warehouse-receipt',
+		label: '仓库收货异常',
+		totalCellCode: 'SUPPLY_ANOMALY_WAREHOUSE_RECEIPT',
+		analysisCellCode: 'SUPPLY_CAT_WAREHOUSE_RECEIPT',
+	},
+	{
+		key: 'iqc-inspection',
+		label: 'IQC检验异常',
+		totalCellCode: 'SUPPLY_ANOMALY_IQC_INSPECTION',
+		analysisCellCode: 'SUPPLY_CAT_IQC_INSPECTION',
+	},
+	{
+		key: 'warehouse-shelving',
+		label: '仓库上架入库异常',
+		totalCellCode: 'SUPPLY_ANOMALY_WAREHOUSE_SHELVING',
+		analysisCellCode: 'SUPPLY_CAT_WAREHOUSE_SHELVING',
+	},
+	{
+		key: 'work-order-prepare',
+		label: '仓库工单备料异常',
+		totalCellCode: 'SUPPLY_ANOMALY_WORK_ORDER_PREPARE',
+		analysisCellCode: 'SUPPLY_CAT_WORK_ORDER_PREPARE',
+	},
+	{
+		key: 'work-order-issue',
+		label: '仓库工单发料异常',
+		totalCellCode: 'SUPPLY_ANOMALY_WORK_ORDER_ISSUE',
+		analysisCellCode: 'SUPPLY_CAT_WORK_ORDER_ISSUE',
+	},
+] as const;
 
 export const s8SupplyMonitoringApi = {
 	/** 获取供应域(S3+S4+S5)的汇总摘要 */
@@ -44,8 +74,5 @@ export const s8SupplyMonitoringApi = {
 
 	/** 获取供应异常类型明细(后端未就绪时降级为空数据) */
 	anomalyTypes: (): Promise<SupplyAnomalyType[]> =>
-		service
-			.get<SupplyAnomalyType[]>('/api/aidop/s8/monitoring/supply/anomaly-types')
-			.then(unwrap)
-			.catch((): SupplyAnomalyType[] => DEMO_ANOMALY_TYPES),
+		loadConfiguredAnomalyTypes('SUPPLY', [...SUPPLY_ANOMALY_SPECS]),
 };

+ 11 - 1
Web/src/views/aidop/s8/components/config/S8CrudConfigPage.vue

@@ -33,6 +33,8 @@ const props = defineProps<{
 	enableTest?: boolean;
 	buildDefault: () => Record<string, unknown>;
 	loadOptions?: () => Promise<Record<string, OptionItem[]>>;
+	/** 基础必填校验通过后执行;返回非空字符串则提示并中止保存 */
+	validateFormExtra?: (form: Record<string, unknown>) => string | null;
 }>();
 
 const loading = ref(false);
@@ -95,9 +97,17 @@ function validateForm() {
 
 async function save() {
 	if (!validateForm()) return;
+	if (props.validateFormExtra) {
+		const msg = props.validateFormExtra(form);
+		if (msg) {
+			ElMessage.warning(msg);
+			return;
+		}
+	}
 	saving.value = true;
 	try {
-		const payload = { ...form, tenantId: 1, factoryId: 1 };
+		const payload =
+			editingId.value > 0 ? { ...form } : { ...form, tenantId: 1, factoryId: 1 };
 		if (editingId.value > 0) {
 			await s8ConfigApi.update(props.endpoint, editingId.value, payload);
 			ElMessage.success('更新成功');

+ 2 - 0
Web/src/views/aidop/s8/config/S8ConfigHubPage.vue

@@ -18,6 +18,8 @@ import AidopDemoShell from '../../components/AidopDemoShell.vue';
 const router = useRouter();
 const cards = [
 	{ path: '/aidop/s8/config/scenes', title: '场景基础配置', desc: '场景编码、启用、排序' },
+	{ path: '/aidop/s8/config/exception-types', title: '异常类型', desc: '类型编码、SLA、责任角色' },
+	{ path: '/aidop/s8/config/dashboard-cells', title: '大屏卡片', desc: '页面/卡片编码、绑定类型、指标与时间窗' },
 	{ path: '/aidop/s8/config/notifications', title: '通知分层', desc: '场景 + 严重度 + 层级' },
 	{ path: '/aidop/s8/config/roles', title: '角色权限', desc: 'S8 动作权限配置' },
 	{ path: '/aidop/s8/config/alert-rules', title: '报警规则', desc: '阈值与触发条件' },

+ 169 - 0
Web/src/views/aidop/s8/config/S8DashboardCellConfigPage.vue

@@ -0,0 +1,169 @@
+<script setup lang="ts" name="aidopS8DashboardCellConfig">
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import { s8ConfigApi } from '../api/s8ConfigApi';
+
+/** 与监控页浮层「分析标题 / 静态指标」对应关系:标题→cellTitle;指标口径→statMetric+timeWindow;色调与静态数仍可由浮层覆盖 */
+const columns = [
+	{ key: 'pageCode', label: '页面', width: 110 },
+	{ key: 'cellCode', label: '卡片编码', width: 200 },
+	{ key: 'cellTitle', label: '卡片标题', width: 160 },
+	{ key: 'bindingType', label: '绑定类型', width: 130 },
+	{ key: 'exceptionTypeCode', label: '异常类型', width: 140 },
+	{ key: 'aggregateScope', label: '聚合范围', width: 130 },
+	{ key: 'statMetric', label: '统计指标', width: 110 },
+	{ key: 'timeWindow', label: '时间窗', width: 100 },
+	{ key: 'deptGroupBy', label: '部门维度', width: 100 },
+	{ key: 'enabled', label: '启用', width: 80 },
+	{ key: 'sortNo', label: '排序', width: 80 },
+];
+
+const fields = [
+	{
+		key: 'pageCode',
+		label: '页面编码',
+		type: 'select',
+		required: true,
+		options: [
+			{ label: '总览 OVERVIEW', value: 'OVERVIEW' },
+			{ label: '交付 DELIVERY', value: 'DELIVERY' },
+			{ label: '生产 PRODUCTION', value: 'PRODUCTION' },
+			{ label: '供应 SUPPLY', value: 'SUPPLY' },
+		],
+	},
+	{
+		key: 'cellCode',
+		label: '卡片编码',
+		type: 'input',
+		required: true,
+		placeholder: '与前端大屏锚点一致,如 DELIVERY_ANOMALY_ORDER_CHANGE',
+	},
+	{
+		key: 'cellTitle',
+		label: '卡片标题',
+		type: 'input',
+		placeholder: '对应浮层「分析标题」;留空则大屏可用组件默认文案',
+	},
+	{
+		key: 'bindingType',
+		label: '绑定类型',
+		type: 'select',
+		required: true,
+		options: [
+			{ label: '异常类型 EXCEPTION_TYPE', value: 'EXCEPTION_TYPE' },
+			{ label: '域聚合 AGGREGATE', value: 'AGGREGATE' },
+			{ label: '自定义 CUSTOM(不按类型过滤)', value: 'CUSTOM' },
+		],
+	},
+	{
+		key: 'exceptionTypeCode',
+		label: '异常类型编码',
+		type: 'select',
+		optionsKey: 'exceptionTypes',
+		placeholder: '绑定异常类型时必选,对应 ado_s8_exception_type.type_code',
+	},
+	{
+		key: 'aggregateScope',
+		label: '聚合范围',
+		type: 'select',
+		options: [
+			{ label: '全部 ALL', value: 'ALL' },
+			{ label: '交付域 DOMAIN_DELIVERY', value: 'DOMAIN_DELIVERY' },
+			{ label: '生产域 DOMAIN_PRODUCTION', value: 'DOMAIN_PRODUCTION' },
+			{ label: '供应域 DOMAIN_SUPPLY', value: 'DOMAIN_SUPPLY' },
+		],
+		placeholder: '绑定域聚合时必选',
+	},
+	{
+		key: 'statMetric',
+		label: '统计指标',
+		type: 'select',
+		required: true,
+		options: [
+			{ label: '未关闭数 OPEN_COUNT(浮层「异常数」)', value: 'OPEN_COUNT' },
+			{ label: '频次 FREQUENCY', value: 'FREQUENCY' },
+			{ label: '均处理时长 AVG_DURATION(浮层「均时」)', value: 'AVG_DURATION' },
+			{ label: '关闭率 CLOSE_RATE(浮层「关闭率」)', value: 'CLOSE_RATE' },
+		],
+	},
+	{
+		key: 'timeWindow',
+		label: '时间窗',
+		type: 'select',
+		required: true,
+		options: [
+			{ label: '今日 TODAY', value: 'TODAY' },
+			{ label: '近 24 小时 LAST_24H', value: 'LAST_24H' },
+			{ label: '近 7 天 LAST_7D', value: 'LAST_7D' },
+			{ label: '近 30 天 LAST_30D', value: 'LAST_30D' },
+		],
+	},
+	{
+		key: 'deptGroupBy',
+		label: '部门聚合维度',
+		type: 'select',
+		required: true,
+		options: [
+			{ label: '责任部门 OWNER', value: 'OWNER' },
+			{ label: '发生部门 OCCUR', value: 'OCCUR' },
+		],
+		placeholder: '明细按部门分组时使用;也可由 cell-data 请求参数覆盖',
+	},
+	{
+		key: 'filterExpression',
+		label: '筛选表达式',
+		type: 'textarea',
+		placeholder: '可选,后续扩展(部门、订单范围等),留空表示不按表达式筛',
+	},
+	{ key: 'enabled', label: '启用', type: 'switch' },
+	{ key: 'sortNo', label: '排序', type: 'number' },
+] as const;
+
+const buildDefault = () => ({
+	pageCode: 'DELIVERY',
+	cellCode: '',
+	cellTitle: '',
+	bindingType: 'EXCEPTION_TYPE',
+	exceptionTypeCode: '',
+	aggregateScope: '',
+	statMetric: 'OPEN_COUNT',
+	timeWindow: 'LAST_24H',
+	filterExpression: '',
+	deptGroupBy: 'OWNER',
+	enabled: true,
+	sortNo: 0,
+});
+
+async function loadOptions() {
+	const types = await s8ConfigApi.exceptionTypes.list({ tenantId: 1, factoryId: 1, enabledOnly: true });
+	return {
+		exceptionTypes: types.map((t) => ({
+			label: `${t.typeName}(${t.typeCode})`,
+			value: t.typeCode,
+		})),
+	};
+}
+
+function validateFormExtra(form: Record<string, unknown>): string | null {
+	const bt = String(form.bindingType ?? '');
+	if (bt === 'EXCEPTION_TYPE' && !String(form.exceptionTypeCode ?? '').trim()) {
+		return '绑定类型为「异常类型」时请填写或选择异常类型编码';
+	}
+	if (bt === 'AGGREGATE' && !String(form.aggregateScope ?? '').trim()) {
+		return '绑定类型为「域聚合」请选择聚合范围';
+	}
+	return null;
+}
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="大屏卡片配置"
+		subtitle="S8 / 配置 / 卡片数据绑定(page+cell、指标、时间窗);浮层「卡片配置」仍可覆盖展示标题、色调与静态预览数"
+		endpoint="/api/aidop/s8/config/dashboard-cells"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+		:load-options="loadOptions"
+		:validate-form-extra="validateFormExtra"
+	/>
+</template>

+ 97 - 0
Web/src/views/aidop/s8/config/S8ExceptionTypeConfigPage.vue

@@ -0,0 +1,97 @@
+<script setup lang="ts" name="aidopS8ExceptionTypeConfig">
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import { s8ConfigApi } from '../api/s8ConfigApi';
+
+const columns = [
+	{ key: 'typeCode', label: '类型编码', width: 180 },
+	{ key: 'typeName', label: '类型名称', width: 180 },
+	{ key: 'domainCode', label: '所属域', width: 120 },
+	{ key: 'sceneCode', label: '场景编码', width: 150 },
+	{ key: 'severityDefault', label: '默认严重度', width: 120 },
+	{ key: 'slaMinutes', label: 'SLA(分钟)', width: 110 },
+	{ key: 'ownerRoleCode', label: '责任角色', width: 180 },
+	{ key: 'enabled', label: '启用', width: 90 },
+	{ key: 'sortNo', label: '排序', width: 90 },
+];
+
+const fields = [
+	{ key: 'typeCode', label: '类型编码', type: 'input', required: true, placeholder: '如 ORDER_CHANGE' },
+	{ key: 'typeName', label: '类型名称', type: 'input', required: true },
+	{
+		key: 'domainCode',
+		label: '所属域',
+		type: 'select',
+		required: true,
+		options: [
+			{ label: '交付', value: 'DELIVERY' },
+			{ label: '生产', value: 'PRODUCTION' },
+			{ label: '供应', value: 'SUPPLY' },
+		],
+	},
+	{ key: 'sceneCode', label: '场景编码', type: 'select', required: true, optionsKey: 'scenes' },
+	{ key: 'severityDefault', label: '默认严重度', type: 'select', required: true, optionsKey: 'severities' },
+	{ key: 'slaMinutes', label: 'SLA(分钟)', type: 'number', required: true },
+	{ key: 'ownerRoleCode', label: '责任角色', type: 'select', required: true, optionsKey: 'roles' },
+	{ key: 'escalateRoleCode', label: '升级角色', type: 'select', optionsKey: 'roles' },
+	{
+		key: 'statsMode',
+		label: '统计模式',
+		type: 'select',
+		required: true,
+		options: [
+			{ label: '全部', value: 'ALL' },
+			{ label: '频率', value: 'FREQUENCY' },
+			{ label: '时长', value: 'DURATION' },
+			{ label: '关闭率', value: 'CLOSE_RATE' },
+		],
+	},
+	{ key: 'mobileVisible', label: '移动端展示', type: 'switch' },
+	{ key: 'enabled', label: '启用', type: 'switch' },
+	{ key: 'sortNo', label: '排序', type: 'number' },
+	{ key: 'icon', label: '图标标识', type: 'input' },
+	{ key: 'remark', label: '备注', type: 'textarea' },
+] as const;
+
+const buildDefault = () => ({
+	typeCode: '',
+	typeName: '',
+	domainCode: 'DELIVERY',
+	sceneCode: '',
+	severityDefault: 'MEDIUM',
+	slaMinutes: 60,
+	ownerRoleCode: '',
+	escalateRoleCode: '',
+	statsMode: 'ALL',
+	mobileVisible: true,
+	enabled: true,
+	sortNo: 0,
+	icon: '',
+	remark: '',
+});
+
+async function loadOptions() {
+	const [scenes, severities, roles] = await Promise.all([
+		s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 }),
+		s8ConfigApi.severities(),
+		s8ConfigApi.list('/api/aidop/s8/config/roles', { tenantId: 1, factoryId: 1 }),
+	]);
+
+	return {
+		scenes: scenes.map((item: any) => ({ label: `${item.sceneName} (${item.sceneCode})`, value: item.sceneCode })),
+		severities,
+		roles: roles.map((item: any) => ({ label: item.roleCode, value: item.roleCode })),
+	};
+}
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="异常类型配置"
+		subtitle="S8 / 配置 / 异常类型"
+		endpoint="/api/aidop/s8/config/exception-types"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+		:load-options="loadOptions"
+	/>
+</template>

+ 3 - 3
Web/src/views/approvalFlow/component/ApprovalPanel.vue

@@ -181,8 +181,8 @@ const searchUsers = async (query: string) => {
 };
 
 const myTask = computed(() => {
-	if (!instance.value?.tasks) return null;
-	return instance.value.tasks.find((t: any) => t.status === 0);
+	if (!instance.value?.tasks || !resolvedUserId.value) return null;
+	return instance.value.tasks.find((t: any) => t.status === 0 && t.assigneeId === resolvedUserId.value);
 });
 
 const isInitiator = computed(() => {
@@ -200,7 +200,7 @@ const loadInstance = async () => {
 			const tlRes = await getTimeline(instance.value.id);
 			timeline.value = tlRes.data?.result ?? [];
 
-			const myT = instance.value.tasks?.find((t: any) => t.status === 0);
+			const myT = instance.value.tasks?.find((t: any) => t.status === 0 && t.assigneeId === resolvedUserId.value);
 			if (myT) {
 				try {
 					const escRes = await getEscalationConfig(myT.id);

+ 4 - 1
Web/src/views/approvalFlow/component/editDialog.vue

@@ -33,7 +33,10 @@
 						</el-col>
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 								<el-form-item label="状态" prop="status" :rules="[{ required: true, message: '状态不能为空', trigger: 'blur' }]">
-									<g-sys-dict code="LabStatusEnum" v-model="state.ruleForm.status" render-as="select" />
+									<el-select v-model="state.ruleForm.status" placeholder="请选择" style="width: 100%">
+											<el-option label="启用" :value="1" />
+											<el-option label="禁用" :value="0" />
+										</el-select>
 								</el-form-item>
 							</el-col>
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">

+ 778 - 0
doc/plans/2026-04-18-s8-verification-loop.md

@@ -0,0 +1,778 @@
+# S8 复检闭环 Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 补齐 S8 中"处理人提交已修复 → 检验人检验 → RESOLVED → 提交关闭申请"的完整业务闭环,引入 `PENDING_VERIFICATION` 中间状态,检验人由处理人提交复检时手选。
+
+**Architecture:** 新增 `PENDING_VERIFICATION` 状态和 `VerifierId` 等字段到主表;在 `S8TaskFlowService` 增加三个方法(submit-verification / approve-verification / reject-verification);前端详情页在 `v-else` 区域按角色+状态控制按钮显隐,检验动作与审批流无关,不受 `hasActiveFlow` 影响。
+
+**Tech Stack:** C# / SqlSugar(后端)、Vue 3 Composition API + Element Plus(前端)、PostgreSQL(DB 迁移用 SQL 文件,非 CodeFirst)
+
+---
+
+## 上下文速查
+
+关键文件路径(执行时直接用绝对路径):
+
+| 文件 | 用途 |
+|---|---|
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8StatusRules.cs` | 状态机边集 |
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8Labels.cs` | 状态中文标签 + 选项列表 |
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs` | 主表实体 |
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs` | 业务流转服务 |
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DictionaryService.cs` | 字典接口 |
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs` | 查询服务(pendingStatuses 在此) |
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs` | DTO 定义 |
+| `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs` | HTTP 控制器 |
+| `AiDOPWarehouse/Web/src/views/aidop/s8/api/s8ExceptionApi.ts` | 前端 API 封装 |
+| `AiDOPWarehouse/Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue` | 前端详情页 |
+
+**现有状态机边集(修改前):**
+```
+NEW->ASSIGNED, NEW->REJECTED
+ASSIGNED->IN_PROGRESS, ASSIGNED->ESCALATED, ASSIGNED->REJECTED
+IN_PROGRESS->RESOLVED, IN_PROGRESS->ESCALATED, IN_PROGRESS->REJECTED
+RESOLVED->CLOSED, RESOLVED->IN_PROGRESS
+ESCALATED->ASSIGNED, ESCALATED->IN_PROGRESS
+REJECTED->NEW
+```
+
+**注意:`IN_PROGRESS->RESOLVED` 边已存在**,但目前无后端方法能触发它。
+
+---
+
+## Task 1: 数据库迁移脚本
+
+**Files:**
+- Create: `AiDOPWarehouse/doc/migrations/2026-04-18_s8_verification_fields.sql`
+
+**Step 1: 编写迁移 SQL**
+
+```sql
+-- 2026-04-18: S8 复检闭环 - 新增检验相关字段
+ALTER TABLE ado_s8_exception
+    ADD COLUMN IF NOT EXISTS verifier_id             BIGINT       NULL,
+    ADD COLUMN IF NOT EXISTS verification_assigned_at TIMESTAMPTZ  NULL,
+    ADD COLUMN IF NOT EXISTS verified_at             TIMESTAMPTZ  NULL,
+    ADD COLUMN IF NOT EXISTS verification_result     VARCHAR(32)  NULL,
+    ADD COLUMN IF NOT EXISTS verification_remark     VARCHAR(2000) NULL;
+
+COMMENT ON COLUMN ado_s8_exception.verifier_id             IS '检验人ID(处理人提交复检时手选)';
+COMMENT ON COLUMN ado_s8_exception.verification_assigned_at IS '提交复检时间';
+COMMENT ON COLUMN ado_s8_exception.verified_at             IS '检验完成时间';
+COMMENT ON COLUMN ado_s8_exception.verification_result     IS 'APPROVED / REJECTED';
+COMMENT ON COLUMN ado_s8_exception.verification_remark     IS '检验意见';
+```
+
+**Step 2: 在开发数据库执行该脚本**
+
+```bash
+psql -U <db_user> -d <db_name> -f AiDOPWarehouse/doc/migrations/2026-04-18_s8_verification_fields.sql
+```
+
+预期:无报错,`\d ado_s8_exception` 能看到 5 个新列。
+
+**Step 3: Commit**
+
+```bash
+git add AiDOPWarehouse/doc/migrations/2026-04-18_s8_verification_fields.sql
+git commit -m "chore: add s8 verification fields migration"
+```
+
+---
+
+## Task 2: 实体 + 状态机 + 标签
+
+**Files:**
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs`
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8StatusRules.cs`
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8Labels.cs`
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DictionaryService.cs`
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs`
+
+**Step 1: 在 `AdoS8Exception.cs` 末尾(`IsDeleted` 字段之前)新增 5 个字段**
+
+在 `SourcePayload` 属性后、类的最后一个 `}` 前插入:
+
+```csharp
+    /// <summary>检验人ID(提交复检时手选)</summary>
+    [SugarColumn(ColumnName = "verifier_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? VerifierId { get; set; }
+
+    /// <summary>提交复检时间</summary>
+    [SugarColumn(ColumnName = "verification_assigned_at", IsNullable = true)]
+    public DateTime? VerificationAssignedAt { get; set; }
+
+    /// <summary>检验完成时间</summary>
+    [SugarColumn(ColumnName = "verified_at", IsNullable = true)]
+    public DateTime? VerifiedAt { get; set; }
+
+    /// <summary>检验结果(APPROVED / REJECTED)</summary>
+    [SugarColumn(ColumnName = "verification_result", Length = 32, IsNullable = true)]
+    public string? VerificationResult { get; set; }
+
+    /// <summary>检验意见</summary>
+    [SugarColumn(ColumnName = "verification_remark", Length = 2000, IsNullable = true)]
+    public string? VerificationRemark { get; set; }
+```
+
+**Step 2: 在 `S8StatusRules.cs` 的 `Edges` HashSet 中新增两条边**
+
+在 `("IN_PROGRESS", "RESOLVED"),` 这行**后面**插入:
+
+```csharp
+        ("IN_PROGRESS", "PENDING_VERIFICATION"),
+        ("PENDING_VERIFICATION", "RESOLVED"),
+        ("PENDING_VERIFICATION", "IN_PROGRESS"),
+```
+
+**Step 3: 在 `S8Labels.cs` 的 `StatusLabel` 方法中新增分支**
+
+在 `"ESCALATED" => "已升级",` 后面插入:
+
+```csharp
+        "PENDING_VERIFICATION" => "待检验",
+```
+
+同时在 `StatusOptions()` 方法的数组中新增 `"PENDING_VERIFICATION"`,放在 `"RESOLVED"` 之后:
+
+将:
+```csharp
+    public static object[] StatusOptions() =>
+        new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "RESOLVED", "CLOSED", "REJECTED", "ESCALATED" }
+```
+改为:
+```csharp
+    public static object[] StatusOptions() =>
+        new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION", "RESOLVED", "CLOSED", "REJECTED", "ESCALATED" }
+```
+
+**Step 4: 在 `S8DictionaryService.cs` 的 `S8_EXCEPTION_STATUS` case 中新增一项**
+
+在 `new { value = "RESOLVED", label = "已处理" },` 后面插入:
+
+```csharp
+                new { value = "PENDING_VERIFICATION", label = "待检验" },
+```
+
+**Step 5: 在 `S8ExceptionService.cs` 的 `pendingStatuses` 数组中新增 `PENDING_VERIFICATION`**
+
+将:
+```csharp
+        var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS" };
+```
+改为:
+```csharp
+        var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION" };
+```
+
+**Step 6: 编译验证**
+
+```bash
+cd AiDOPWarehouse/server
+dotnet build --no-restore 2>&1 | tail -20
+```
+
+预期:`Build succeeded`,无 error。
+
+**Step 7: Commit**
+
+```bash
+git add AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs \
+        AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8StatusRules.cs \
+        AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8Labels.cs \
+        AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DictionaryService.cs \
+        AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs
+git commit -m "feat: add PENDING_VERIFICATION status to S8 state machine"
+```
+
+---
+
+## Task 3: DTO 新增
+
+**Files:**
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs`
+
+**Step 1: 在文件末尾追加三个 DTO 类**
+
+```csharp
+public class AdoS8SubmitVerificationDto
+{
+    public long VerifierId { get; set; }
+    public string? Remark { get; set; }
+}
+
+public class AdoS8ApproveVerificationDto
+{
+    public string? Remark { get; set; }
+}
+
+public class AdoS8RejectVerificationDto
+{
+    public string Remark { get; set; } = string.Empty;
+}
+```
+
+同时在 `AdoS8ExceptionDetailDto` 类中(`ActiveFlowBizType` 属性后)新增检验字段的输出:
+
+```csharp
+    public long? VerifierId { get; set; }
+    public string? VerifierName { get; set; }
+    public DateTime? VerificationAssignedAt { get; set; }
+    public DateTime? VerifiedAt { get; set; }
+    public string? VerificationResult { get; set; }
+    public string? VerificationRemark { get; set; }
+```
+
+**Step 2: 编译验证**
+
+```bash
+cd AiDOPWarehouse/server
+dotnet build --no-restore 2>&1 | tail -5
+```
+
+**Step 3: Commit**
+
+```bash
+git add AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs
+git commit -m "feat: add verification DTOs for S8"
+```
+
+---
+
+## Task 4: 后端服务方法
+
+**Files:**
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs`
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs`
+
+**Step 1: 在 `S8TaskFlowService.cs` 的 `CommentAsync` 方法**前**(即 `private Task<AdoS8Exception?> LoadAsync` 之前)插入三个新方法**
+
+```csharp
+    public async Task<AdoS8Exception> SubmitVerificationAsync(
+        long id, long tenantId, long factoryId,
+        long currentUserId, long verifierId, string? remark)
+    {
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (e.AssigneeId != currentUserId)
+            throw new S8BizException("只有当前处理人才能提交复检");
+        if (verifierId <= 0)
+            throw new S8BizException("请选择检验人");
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "PENDING_VERIFICATION"))
+            throw new S8BizException($"状态 {e.Status} 不可提交复检");
+
+        var from = e.Status;
+        e.Status = "PENDING_VERIFICATION";
+        e.VerifierId = verifierId;
+        e.VerificationAssignedAt = DateTime.Now;
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "VERIFY_SUBMITTED", "提交复检", from, "PENDING_VERIFICATION",
+                currentUserId, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+
+    public async Task<AdoS8Exception> ApproveVerificationAsync(
+        long id, long tenantId, long factoryId,
+        long currentUserId, string? remark)
+    {
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (e.VerifierId != currentUserId)
+            throw new S8BizException("只有指定检验人才能检验通过");
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "RESOLVED"))
+            throw new S8BizException($"状态 {e.Status} 不可检验通过");
+
+        var from = e.Status;
+        e.Status = "RESOLVED";
+        e.VerifiedAt = DateTime.Now;
+        e.VerificationResult = "APPROVED";
+        e.VerificationRemark = remark;
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "VERIFY_APPROVED", "检验通过", from, "RESOLVED",
+                currentUserId, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+
+    public async Task<AdoS8Exception> RejectVerificationAsync(
+        long id, long tenantId, long factoryId,
+        long currentUserId, string remark)
+    {
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (e.VerifierId != currentUserId)
+            throw new S8BizException("只有指定检验人才能检验退回");
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS"))
+            throw new S8BizException($"状态 {e.Status} 不可检验退回");
+        if (string.IsNullOrWhiteSpace(remark))
+            throw new S8BizException("检验退回必须填写退回原因");
+
+        var from = e.Status;
+        e.Status = "IN_PROGRESS";
+        e.VerifiedAt = DateTime.Now;
+        e.VerificationResult = "REJECTED";
+        e.VerificationRemark = remark;
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "VERIFY_REJECTED", "检验退回", from, "IN_PROGRESS",
+                currentUserId, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+```
+
+**Step 2: 在 `S8ExceptionService.GetDetailAsync` 的 Select 投影中新增检验字段**
+
+找到 `Select((e, sc) => new AdoS8ExceptionDetailDto` 投影块,在 `ActiveFlowBizType = e.ActiveFlowBizType` 后追加:
+
+```csharp
+                VerifierId = e.VerifierId,
+                VerificationAssignedAt = e.VerificationAssignedAt,
+                VerifiedAt = e.VerifiedAt,
+                VerificationResult = e.VerificationResult,
+                VerificationRemark = e.VerificationRemark,
+```
+
+**Step 3: 在 `S8ExceptionService.FillDisplayNamesAsync` 中填充 `VerifierName`**
+
+在 `empIds` 收集处,将:
+```csharp
+        var empIds = list
+            .Select(x => x.AssigneeId ?? 0L)
+            .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.ReporterId ?? 0L))
+```
+改为:
+```csharp
+        var empIds = list
+            .Select(x => x.AssigneeId ?? 0L)
+            .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.ReporterId ?? 0L))
+            .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.VerifierId ?? 0L))
+```
+
+在 `FillDisplayNamesAsync` 的 `foreach` 里,在 `detail.ReporterName = ...` 后追加:
+```csharp
+                detail.VerifierName = detail.VerifierId.HasValue ? empMap.GetValueOrDefault(detail.VerifierId.Value) : null;
+```
+
+**Step 4: 编译验证**
+
+```bash
+cd AiDOPWarehouse/server
+dotnet build --no-restore 2>&1 | tail -5
+```
+
+预期:`Build succeeded`。
+
+**Step 5: Commit**
+
+```bash
+git add AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs \
+        AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs
+git commit -m "feat: add submit/approve/reject verification methods to S8TaskFlowService"
+```
+
+---
+
+## Task 5: Controller 新增三个端点
+
+**Files:**
+- Modify: `AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs`
+
+**Step 1: 在 `CommentAsync` Action 前插入三个新 Action**
+
+```csharp
+    [HttpPost("{id:long}/submit-verification")]
+    public async Task<IActionResult> SubmitVerificationAsync(long id,
+        [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromBody] AdoS8SubmitVerificationDto? body = null)
+    {
+        try
+        {
+            // currentUserId 实际项目接入 JWT 后从 HttpContext.User 取;Demo 阶段从 body 或 query 临时传入
+            var currentUserId = body?.VerifierId > 0 ? 0L : 0L; // placeholder,见 Step 2
+            var e = await _taskFlowSvc.SubmitVerificationAsync(
+                id, tenantId, factoryId,
+                /* currentUserId */ HttpContext.Request.Query.TryGetValue("currentUserId", out var v) && long.TryParse(v, out var uid) ? uid : 0L,
+                body?.VerifierId ?? 0,
+                body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status, verifierId = e.VerifierId });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPost("{id:long}/approve-verification")]
+    public async Task<IActionResult> ApproveVerificationAsync(long id,
+        [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromQuery] long currentUserId = 0,
+        [FromBody] AdoS8ApproveVerificationDto? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.ApproveVerificationAsync(id, tenantId, factoryId, currentUserId, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPost("{id:long}/reject-verification")]
+    public async Task<IActionResult> RejectVerificationAsync(long id,
+        [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromQuery] long currentUserId = 0,
+        [FromBody] AdoS8RejectVerificationDto? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.RejectVerificationAsync(id, tenantId, factoryId, currentUserId, body?.Remark ?? string.Empty);
+            return Ok(new { id = e.Id, status = e.Status });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+```
+
+> **说明:** Demo 阶段没有 JWT 鉴权,`currentUserId` 通过 query 参数传入,与现有 `claim`/`transfer` 等接口的 `tenantId`/`factoryId` 风格保持一致。生产接入 JWT 后改从 `HttpContext.User` 取即可,服务层逻辑不用改。
+
+**Step 2: 清理 `submit-verification` 中的 placeholder 注释**
+
+将上面 Step 1 中 `submit-verification` Action 的 currentUserId 提取行简化为:
+
+```csharp
+    [HttpPost("{id:long}/submit-verification")]
+    public async Task<IActionResult> SubmitVerificationAsync(long id,
+        [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromQuery] long currentUserId = 0,
+        [FromBody] AdoS8SubmitVerificationDto? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.SubmitVerificationAsync(
+                id, tenantId, factoryId, currentUserId, body?.VerifierId ?? 0, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status, verifierId = e.VerifierId });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+```
+
+**Step 3: 编译并确认 Swagger 中有三个新端点**
+
+```bash
+cd AiDOPWarehouse/server
+dotnet build --no-restore 2>&1 | tail -5
+```
+
+启动后访问 `/swagger`,搜索 `verification`,应看到:
+- `POST /api/aidop/s8/exceptions/{id}/submit-verification`
+- `POST /api/aidop/s8/exceptions/{id}/approve-verification`
+- `POST /api/aidop/s8/exceptions/{id}/reject-verification`
+
+**Step 4: Commit**
+
+```bash
+git add AiDOPWarehouse/server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs
+git commit -m "feat: add submit/approve/reject-verification endpoints to S8 controller"
+```
+
+---
+
+## Task 6: 前端 API 封装
+
+**Files:**
+- Modify: `AiDOPWarehouse/Web/src/views/aidop/s8/api/s8ExceptionApi.ts`
+
+**Step 1: 在 `S8ExceptionRow` 接口新增检验字段**
+
+在 `activeFlowBizType?: string | null;` 后追加:
+
+```ts
+	verifierId?: number | null;
+	verifierName?: string | null;
+	verificationAssignedAt?: string | null;
+	verifiedAt?: string | null;
+	verificationResult?: string | null;
+	verificationRemark?: string | null;
+```
+
+**Step 2: 在 `s8ExceptionApi` 对象末尾追加三个方法**
+
+```ts
+	submitVerification: (id: number, body: { verifierId: number; remark?: string }, currentUserId: number) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/submit-verification?currentUserId=${currentUserId}`, body).then(unwrap),
+	approveVerification: (id: number, body: { remark?: string }, currentUserId: number) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/approve-verification?currentUserId=${currentUserId}`, body).then(unwrap),
+	rejectVerification: (id: number, body: { remark: string }, currentUserId: number) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/reject-verification?currentUserId=${currentUserId}`, body).then(unwrap),
+```
+
+**Step 3: Commit**
+
+```bash
+git add AiDOPWarehouse/Web/src/views/aidop/s8/api/s8ExceptionApi.ts
+git commit -m "feat: add verification API methods to s8ExceptionApi"
+```
+
+---
+
+## Task 7: 前端详情页 — 状态计算 + 弹窗逻辑
+
+**Files:**
+- Modify: `AiDOPWarehouse/Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue`
+
+**Step 1: 在 `<script setup>` 里的 computed 区域新增检验相关 computed**
+
+在 `const canClose = computed(...)` 后追加:
+
+```ts
+// Demo 阶段:currentUserId 从 detail 里的 assigneeId 模拟(实际接入 JWT 后改 useUserInfo)
+const currentUserId = computed(() => Number(detail.value?.assigneeId ?? 0));
+const canSubmitVerification = computed(
+	() => currentStatus.value === 'IN_PROGRESS' && !!detail.value?.assigneeId
+);
+const canApproveVerification = computed(
+	() => currentStatus.value === 'PENDING_VERIFICATION' && !!detail.value?.verifierId
+);
+const canRejectVerification = computed(
+	() => currentStatus.value === 'PENDING_VERIFICATION' && !!detail.value?.verifierId
+);
+```
+
+> **说明:** Demo 阶段无真实登录态,所有人都能看到所有按钮(按状态控制);实际接入 JWT 后把 `currentUserId` 改成 `useUserInfo().userInfos.id` 并对比 `assigneeId`/`verifierId` 即可。
+
+**Step 2: 在 `actionTitle()` 函数的映射对象中新增三项**
+
+将:
+```ts
+		{
+			claim: '认领',
+			transfer: '转派',
+			upgrade: '升级',
+			reject: '驳回',
+			close: '提交关闭申请',
+			comment: '补充说明',
+		}[dialogMode.value] ?? '动作'
+```
+改为:
+```ts
+		{
+			claim: '认领',
+			transfer: '转派',
+			upgrade: '升级',
+			reject: '驳回',
+			close: '提交关闭申请',
+			comment: '补充说明',
+			submitVerification: '提交复检',
+			approveVerification: '检验通过',
+			rejectVerification: '检验退回',
+		}[dialogMode.value] ?? '动作'
+```
+
+**Step 3: 在 `submitAction()` 函数的 if/else 链末尾,`comment` 之前插入三个新分支**
+
+```ts
+		} else if (dialogMode.value === 'submitVerification') {
+			if (!actionForm.assigneeId) { ElMessage.warning('请选择检验人'); return; }
+			await s8ExceptionApi.submitVerification(
+				id,
+				{ verifierId: actionForm.assigneeId, remark: actionForm.remark || undefined },
+				currentUserId.value
+			);
+		} else if (dialogMode.value === 'approveVerification') {
+			await s8ExceptionApi.approveVerification(
+				id,
+				{ remark: actionForm.remark || undefined },
+				currentUserId.value
+			);
+		} else if (dialogMode.value === 'rejectVerification') {
+			if (!actionForm.remark) { ElMessage.warning('检验退回必须填写退回原因'); return; }
+			await s8ExceptionApi.rejectVerification(
+				id,
+				{ remark: actionForm.remark },
+				currentUserId.value
+			);
+		} else if (dialogMode.value === 'comment') {
+```
+
+注意:将原来的 `} else if (dialogMode.value === 'comment') {` 行保持不变,只在其前面插入三个新分支。
+
+**Step 4: Commit**
+
+```bash
+git add AiDOPWarehouse/Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue
+git commit -m "feat: add verification computed and action handlers to S8TaskDetailPage"
+```
+
+---
+
+## Task 8: 前端详情页 — 操作面板按钮
+
+**Files:**
+- Modify: `AiDOPWarehouse/Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue`
+
+**Step 1: 在 `<template>` 的 `v-else` 操作面板区新增三个检验按钮**
+
+找到:
+```html
+					<div class="action-grid">
+						<el-button type="primary" :disabled="!canClaim" @click="openAction('claim')">认领</el-button>
+						<el-button :disabled="!canTransfer" @click="openAction('transfer')">转派</el-button>
+						<el-button type="warning" :disabled="!canUpgrade" @click="openAction('upgrade')">升级</el-button>
+						<el-button type="danger" :disabled="!canReject" @click="openAction('reject')">驳回</el-button>
+						<el-button type="success" :disabled="!canClose" @click="openAction('close')">提交关闭申请</el-button>
+						<el-button @click="openAction('comment')">补充说明</el-button>
+					</div>
+```
+
+替换为:
+```html
+					<div class="action-grid">
+						<el-button type="primary" :disabled="!canClaim" @click="openAction('claim')">认领</el-button>
+						<el-button :disabled="!canTransfer" @click="openAction('transfer')">转派</el-button>
+						<el-button type="warning" :disabled="!canUpgrade" @click="openAction('upgrade')">升级</el-button>
+						<el-button type="danger" :disabled="!canReject" @click="openAction('reject')">驳回</el-button>
+						<el-button type="primary" :disabled="!canSubmitVerification" @click="openAction('submitVerification')">提交复检</el-button>
+						<el-button type="success" :disabled="!canApproveVerification" @click="openAction('approveVerification')">检验通过</el-button>
+						<el-button type="warning" :disabled="!canRejectVerification" @click="openAction('rejectVerification')">检验退回</el-button>
+						<el-button type="success" :disabled="!canClose" @click="openAction('close')">提交关闭申请</el-button>
+						<el-button @click="openAction('comment')">补充说明</el-button>
+					</div>
+```
+
+**Step 2: 在弹窗 `<el-form>` 中新增"检验人"选择项(仅 submitVerification 模式显示)**
+
+找到已有的处理人选择:
+```html
+				<el-form-item v-if="dialogMode === 'claim' || dialogMode === 'transfer'" label="处理人" required>
+```
+
+改为:
+```html
+				<el-form-item v-if="dialogMode === 'claim' || dialogMode === 'transfer'" label="处理人" required>
+					<el-select v-model="actionForm.assigneeId" style="width: 100%" filterable clearable>
+						<el-option v-for="item in employees" :key="item.id" :label="item.name" :value="item.id" />
+					</el-select>
+				</el-form-item>
+				<el-form-item v-if="dialogMode === 'submitVerification'" label="检验人" required>
+					<el-select v-model="actionForm.assigneeId" style="width: 100%" filterable clearable>
+						<el-option v-for="item in employees" :key="item.id" :label="item.name" :value="item.id" />
+					</el-select>
+				</el-form-item>
+```
+
+**Step 3: 更新弹窗备注区的 `:required` 逻辑**
+
+找到:
+```html
+				<el-form-item
+					:label="dialogMode === 'close' ? '处置措施' : dialogMode === 'upgrade' ? '升级原因' : '备注'"
+					:required="dialogMode === 'upgrade' || dialogMode === 'close'"
+				>
+```
+
+改为:
+```html
+				<el-form-item
+					:label="dialogMode === 'close' ? '处置措施' : dialogMode === 'upgrade' ? '升级原因' : dialogMode === 'rejectVerification' ? '退回原因' : '备注'"
+					:required="dialogMode === 'upgrade' || dialogMode === 'close' || dialogMode === 'rejectVerification'"
+				>
+```
+
+**Step 4: 在详情卡片中展示检验人信息**
+
+找到 `<el-descriptions-item label="处理人">` 的那行,在其后插入:
+
+```html
+						<el-descriptions-item label="检验人">{{ detail.verifierName || detail.verifierId || '—' }}</el-descriptions-item>
+						<el-descriptions-item label="检验结果">{{ detail.verificationResult || '—' }}</el-descriptions-item>
+```
+
+**Step 5: Commit**
+
+```bash
+git add AiDOPWarehouse/Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue
+git commit -m "feat: add verification buttons and verifier info to S8TaskDetailPage"
+```
+
+---
+
+## Task 9: 联调验证
+
+**Step 1: 启动后端**
+
+```bash
+cd AiDOPWarehouse/server
+dotnet run --project Admin.NET.Web.Entry
+```
+
+**Step 2: 在 Swagger 中执行完整链路**
+
+按以下顺序操作(每步记录返回的 `status`):
+
+| 步骤 | 接口 | 预期 status |
+|---|---|---|
+| 1 | 已有 IN_PROGRESS 异常(或先 claim + 手动改状态) | `IN_PROGRESS` |
+| 2 | `POST /{id}/submit-verification` body: `{"verifierId":2}` query: `currentUserId=assigneeId值` | `PENDING_VERIFICATION` |
+| 3 | `GET /{id}` | 返回含 `verifierId`、`verificationAssignedAt` |
+| 4 | `GET /{id}/timeline` | 末尾出现 `VERIFY_SUBMITTED` 条目 |
+| 5 | `POST /{id}/reject-verification` body: `{"remark":"问题未修复"}` query: `currentUserId=verifierId值` | `IN_PROGRESS` |
+| 6 | `GET /{id}/timeline` | 末尾出现 `VERIFY_REJECTED` 条目 |
+| 7 | 重复步骤 2 | `PENDING_VERIFICATION` |
+| 8 | `POST /{id}/approve-verification` query: `currentUserId=verifierId值` | `RESOLVED` |
+| 9 | `GET /{id}/timeline` | 末尾出现 `VERIFY_APPROVED` |
+| 10 | `POST /{id}/close` body: `{"remark":"已修复关闭"}` | 触发 EXCEPTION_CLOSURE 审批流,status 视 handler 而定 |
+
+**Step 3: 前端验证**
+
+启动前端开发服务器:
+```bash
+cd AiDOPWarehouse/Web
+npm run dev
+```
+
+打开详情页,按上述 10 步在页面上点击验证:
+- IN_PROGRESS 状态:显示"提交复检"按钮(可点),"检验通过"/"检验退回"禁用
+- PENDING_VERIFICATION 状态:显示"检验通过"/"检验退回"可点,"提交复检"禁用
+- RESOLVED 状态:显示"提交关闭申请"可点
+
+**Step 4: 验证非法操作被拒绝**
+
+- 以错误的 `currentUserId`(非 assigneeId)调 `submit-verification` → 预期 400 `只有当前处理人才能提交复检`
+- 以错误的 `currentUserId`(非 verifierId)调 `approve-verification` → 预期 400 `只有指定检验人才能检验通过`
+- `approve-verification` 在状态非 `PENDING_VERIFICATION` 时调用 → 预期 400
+
+**Step 5: Final Commit**
+
+```bash
+git add .
+git commit -m "feat: S8 verification loop complete - submit/approve/reject verification with PENDING_VERIFICATION state"
+```
+
+---
+
+## 状态机最终结构(完成后)
+
+```
+NEW → ASSIGNED → IN_PROGRESS → PENDING_VERIFICATION ⇄ IN_PROGRESS
+                              ↓
+                            RESOLVED → CLOSED(经 EXCEPTION_CLOSURE 审批)
+IN_PROGRESS → ESCALATED → ASSIGNED(经 EXCEPTION_ESCALATION 审批)
+各状态 → REJECTED → NEW
+```
+
+## 验收清单
+
+- [ ] `PENDING_VERIFICATION` 在字典接口 `GET /api/aidop/s8/exceptions/filter-options` 的 `statuses` 中出现
+- [ ] 时间线出现 `VERIFY_SUBMITTED` / `VERIFY_APPROVED` / `VERIFY_REJECTED` 三种条目
+- [ ] `PENDING_VERIFICATION` 期间调 `/close` 返回 400(`RESOLVED->CLOSED` 边要求当前状态为 RESOLVED)
+- [ ] 检验退回后状态回到 `IN_PROGRESS`,可以再次提交复检
+- [ ] `EXCEPTION_CLOSURE` 仍只在 RESOLVED 状态发起,与检验环节无关

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDashboardCellsController.cs

@@ -0,0 +1,39 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/config/dashboard-cells")]
+[NonUnify]
+public class AdoS8ConfigDashboardCellsController : ControllerBase
+{
+    private readonly S8DashboardCellConfigService _svc;
+
+    public AdoS8ConfigDashboardCellsController(S8DashboardCellConfigService svc) => _svc = svc;
+
+    [HttpGet]
+    public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
+        => Ok(await _svc.ListAsync(tenantId, factoryId));
+
+    [HttpPost]
+    public async Task<IActionResult> CreateAsync([FromBody] AdoS8DashboardCellConfig body)
+    {
+        try { return Ok(await _svc.CreateAsync(body)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPut("{id:long}")]
+    public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8DashboardCellConfig body)
+    {
+        try { return Ok(await _svc.UpdateAsync(id, body)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpDelete("{id:long}")]
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        await _svc.DeleteAsync(id);
+        return Ok();
+    }
+}

+ 57 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigExceptionTypesController.cs

@@ -0,0 +1,57 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/config/exception-types")]
+[NonUnify]
+public class AdoS8ConfigExceptionTypesController : ControllerBase
+{
+    private readonly S8ExceptionTypeService _svc;
+
+    public AdoS8ConfigExceptionTypesController(S8ExceptionTypeService svc) => _svc = svc;
+
+    [HttpGet]
+    public async Task<IActionResult> ListAsync(
+        [FromQuery] long tenantId = 1,
+        [FromQuery] long factoryId = 1,
+        [FromQuery] string? domainCode = null,
+        [FromQuery] bool? enabledOnly = null)
+        => Ok(await _svc.ListAsync(tenantId, factoryId, domainCode, enabledOnly));
+
+    [HttpGet("{typeCode}")]
+    public async Task<IActionResult> GetAsync(string typeCode, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
+    {
+        var e = await _svc.GetByCodeAsync(tenantId, factoryId, typeCode);
+        return e is null ? NotFound() : Ok(e);
+    }
+
+    [HttpPost]
+    public async Task<IActionResult> CreateAsync([FromBody] AdoS8ExceptionType body)
+    {
+        try { return Ok(await _svc.CreateAsync(body)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPut("{id:long}")]
+    public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8ExceptionType body)
+    {
+        try { return Ok(await _svc.UpdateAsync(id, body)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPut("{id:long}/enabled")]
+    public async Task<IActionResult> SetEnabledAsync(long id, [FromQuery] bool enabled)
+    {
+        try { await _svc.SetEnabledAsync(id, enabled); return Ok(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpDelete("{id:long}")]
+    public async Task<IActionResult> DeleteAsync(long id)
+    {
+        await _svc.DeleteAsync(id);
+        return Ok();
+    }
+}

+ 17 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DashboardController.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
@@ -8,8 +9,13 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 public class AdoS8DashboardController : ControllerBase
 {
     private readonly S8DashboardService _svc;
+    private readonly S8DashboardCellDataService _cellSvc;
 
-    public AdoS8DashboardController(S8DashboardService svc) => _svc = svc;
+    public AdoS8DashboardController(S8DashboardService svc, S8DashboardCellDataService cellSvc)
+    {
+        _svc = svc;
+        _cellSvc = cellSvc;
+    }
 
     [HttpGet("overview")]
     public async Task<IActionResult> OverviewAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
@@ -40,4 +46,14 @@ public class AdoS8DashboardController : ControllerBase
         [FromQuery] string dim = "object",
         [FromQuery] int days = 14) =>
         Ok(await _svc.GetDimTrendsAsync(tenantId, factoryId, dim, days));
+
+    /// <summary>
+    /// 按配置获取单个大屏卡片的数据。配置来源 ado_s8_dashboard_cell_config。
+    /// </summary>
+    [HttpGet("cell-data")]
+    public async Task<IActionResult> CellDataAsync([FromQuery] AdoS8CellDataQueryDto q)
+    {
+        try { return Ok(await _cellSvc.GetAsync(q)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 }

+ 55 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8CellDataDto.cs

@@ -0,0 +1,55 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.S8;
+
+/// <summary>
+/// 大屏卡片数据出参(GET /api/aidop/s8/dashboard/cell-data)。
+/// 结构平铺:value 为主指标;breakdown 为可选的明细分组(按部门或异常类型等)。
+/// </summary>
+public class AdoS8CellDataDto
+{
+    public string CellCode { get; set; } = string.Empty;
+    public string PageCode { get; set; } = string.Empty;
+
+    /// <summary>卡片标题(来自配置 cell_title;若空由前端走组件默认)</summary>
+    public string? Title { get; set; }
+
+    /// <summary>binding_type:EXCEPTION_TYPE / AGGREGATE / CUSTOM</summary>
+    public string BindingType { get; set; } = string.Empty;
+
+    /// <summary>统计口径:OPEN_COUNT / FREQUENCY / AVG_DURATION / CLOSE_RATE</summary>
+    public string StatMetric { get; set; } = string.Empty;
+
+    /// <summary>时间窗:TODAY / LAST_24H / LAST_7D / LAST_30D</summary>
+    public string TimeWindow { get; set; } = string.Empty;
+
+    /// <summary>主指标值(按 stat_metric 语义:计数为 int 语义;百分比 0-100;时长为小时)</summary>
+    public double Value { get; set; }
+
+    /// <summary>附加明细(部门分组 / 类型分组 / 趋势等;CUSTOM 卡片可为空由前端保留原实现)</summary>
+    public List<AdoS8CellBreakdownItem> Breakdown { get; set; } = new();
+
+    /// <summary>未配置 / 未启用时的说明(便于前端区分"空数据"与"未配置")</summary>
+    public string? Message { get; set; }
+}
+
+/// <summary>
+/// 卡片明细分组项。label 含义由 groupBy 决定(OWNER / OCCUR / TYPE 等)。
+/// </summary>
+public class AdoS8CellBreakdownItem
+{
+    public string Label { get; set; } = string.Empty;
+    public double Value { get; set; }
+    public string? Code { get; set; }
+}
+
+/// <summary>卡片数据查询入参</summary>
+public class AdoS8CellDataQueryDto
+{
+    public long TenantId { get; set; } = 1;
+    public long FactoryId { get; set; } = 1;
+    public string PageCode { get; set; } = string.Empty;
+    public string CellCode { get; set; } = string.Empty;
+    /// <summary>可选:ALL 或具体 orderId(预留未落地)</summary>
+    public string? OrderScope { get; set; }
+    /// <summary>可选:OWNER / OCCUR,覆盖配置中的 dept_group_by</summary>
+    public string? DeptGroupBy { get; set; }
+}

+ 73 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DashboardCellConfig.cs

@@ -0,0 +1,73 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8 大屏卡片内容配置。描述"哪个页面的哪个卡片显示什么"。
+/// UI 视觉层保持现状,本表仅承载卡片的数据绑定与统计口径配置。
+/// 采用"全局基线 + 工厂可覆盖"策略:tenant_id=0 / factory_id=0 表示全局默认。
+/// </summary>
+[SugarTable("ado_s8_dashboard_cell_config", "S8 大屏卡片配置")]
+[SugarIndex("uk_ado_s8_dashboard_cell_config_tenant_factory_page_cell", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(PageCode), OrderByType.Asc, nameof(CellCode), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("idx_ado_s8_dashboard_cell_config_page_enabled_sort", nameof(PageCode), OrderByType.Asc, nameof(Enabled), OrderByType.Asc, nameof(SortNo), OrderByType.Asc)]
+public class AdoS8DashboardCellConfig
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
+    public long FactoryId { get; set; }
+
+    /// <summary>归属页面:OVERVIEW / DELIVERY / PRODUCTION / SUPPLY</summary>
+    [SugarColumn(ColumnName = "page_code", Length = 32)]
+    public string PageCode { get; set; } = string.Empty;
+
+    /// <summary>卡片编码,页面内唯一。与前端视觉结构中的稳定锚点(grid-item i、v-for key)对齐。</summary>
+    [SugarColumn(ColumnName = "cell_code", Length = 64)]
+    public string CellCode { get; set; } = string.Empty;
+
+    /// <summary>卡片标题(覆盖前端默认;留空时前端使用组件内默认标题)</summary>
+    [SugarColumn(ColumnName = "cell_title", Length = 128, IsNullable = true)]
+    public string? CellTitle { get; set; }
+
+    /// <summary>绑定类型:EXCEPTION_TYPE(绑单个异常类型) / AGGREGATE(按域聚合) / CUSTOM(保持现有逻辑)</summary>
+    [SugarColumn(ColumnName = "binding_type", Length = 16)]
+    public string BindingType { get; set; } = "CUSTOM";
+
+    /// <summary>binding_type=EXCEPTION_TYPE 时使用,关联 ado_s8_exception_type.type_code</summary>
+    [SugarColumn(ColumnName = "exception_type_code", Length = 64, IsNullable = true)]
+    public string? ExceptionTypeCode { get; set; }
+
+    /// <summary>binding_type=AGGREGATE 时使用:DOMAIN_DELIVERY / DOMAIN_PRODUCTION / DOMAIN_SUPPLY / ALL</summary>
+    [SugarColumn(ColumnName = "aggregate_scope", Length = 32, IsNullable = true)]
+    public string? AggregateScope { get; set; }
+
+    /// <summary>统计指标:OPEN_COUNT / FREQUENCY / AVG_DURATION / CLOSE_RATE</summary>
+    [SugarColumn(ColumnName = "stat_metric", Length = 16)]
+    public string StatMetric { get; set; } = "OPEN_COUNT";
+
+    /// <summary>时间窗:TODAY / LAST_24H / LAST_7D / LAST_30D</summary>
+    [SugarColumn(ColumnName = "time_window", Length = 16)]
+    public string TimeWindow { get; set; } = "LAST_24H";
+
+    /// <summary>额外筛选表达式(按部门、订单范围等),留给后续扩展</summary>
+    [SugarColumn(ColumnName = "filter_expression", Length = 1000, IsNullable = true)]
+    public string? FilterExpression { get; set; }
+
+    /// <summary>部门聚合维度:OWNER(责任部门,默认) / OCCUR(发生部门)。仅 binding_type=AGGREGATE 且聚合含部门时生效。</summary>
+    [SugarColumn(ColumnName = "dept_group_by", Length = 8)]
+    public string DeptGroupBy { get; set; } = "OWNER";
+
+    [SugarColumn(ColumnName = "enabled", ColumnDataType = "boolean")]
+    public bool Enabled { get; set; } = true;
+
+    [SugarColumn(ColumnName = "sort_no")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 76 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionType.cs

@@ -0,0 +1,76 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8 异常类型配置。定义"什么算异常"的主数据表,由运维/管理员维护。
+/// 采用"全局基线 + 工厂可覆盖"策略:tenant_id=0 / factory_id=0 表示全局默认配置。
+/// </summary>
+[SugarTable("ado_s8_exception_type", "S8 异常类型配置")]
+[SugarIndex("uk_ado_s8_exception_type_tenant_factory_code", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(TypeCode), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("idx_ado_s8_exception_type_domain_enabled", nameof(DomainCode), OrderByType.Asc, nameof(Enabled), OrderByType.Asc)]
+public class AdoS8ExceptionType
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
+    public long FactoryId { get; set; }
+
+    /// <summary>异常类型编码(如 ORDER_CHANGE、EQUIP_FAULT)。同一 tenant+factory 内唯一。</summary>
+    [SugarColumn(ColumnName = "type_code", Length = 64)]
+    public string TypeCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "type_name", Length = 128)]
+    public string TypeName { get; set; } = string.Empty;
+
+    /// <summary>所属域:DELIVERY(S1+S7) / PRODUCTION(S2+S6) / SUPPLY(S3-S5)</summary>
+    [SugarColumn(ColumnName = "domain_code", Length = 32)]
+    public string DomainCode { get; set; } = string.Empty;
+
+    /// <summary>关联场景(复用 AdoS8SceneConfig.SceneCode)</summary>
+    [SugarColumn(ColumnName = "scene_code", Length = 64)]
+    public string SceneCode { get; set; } = string.Empty;
+
+    /// <summary>默认严重度:LOW / MEDIUM / HIGH / CRITICAL</summary>
+    [SugarColumn(ColumnName = "severity_default", Length = 16)]
+    public string SeverityDefault { get; set; } = "MEDIUM";
+
+    /// <summary>默认 SLA(分钟),超时触发报警</summary>
+    [SugarColumn(ColumnName = "sla_minutes")]
+    public int SlaMinutes { get; set; }
+
+    /// <summary>默认责任角色编码(关联 AdoS8RolePermissionConfig.RoleCode)</summary>
+    [SugarColumn(ColumnName = "owner_role_code", Length = 64, IsNullable = true)]
+    public string? OwnerRoleCode { get; set; }
+
+    /// <summary>超时升级角色编码</summary>
+    [SugarColumn(ColumnName = "escalate_role_code", Length = 64, IsNullable = true)]
+    public string? EscalateRoleCode { get; set; }
+
+    /// <summary>统计口径:FREQUENCY / DURATION / CLOSE_RATE / ALL</summary>
+    [SugarColumn(ColumnName = "stats_mode", Length = 16)]
+    public string StatsMode { get; set; } = "ALL";
+
+    [SugarColumn(ColumnName = "mobile_visible", ColumnDataType = "boolean")]
+    public bool MobileVisible { get; set; } = true;
+
+    [SugarColumn(ColumnName = "icon", Length = 64, IsNullable = true)]
+    public string? Icon { get; set; }
+
+    [SugarColumn(ColumnName = "enabled", ColumnDataType = "boolean")]
+    public bool Enabled { get; set; } = true;
+
+    [SugarColumn(ColumnName = "sort_no")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnName = "remark", Length = 500, IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 172 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8DashboardCellConfigSeedData.cs

@@ -0,0 +1,172 @@
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// S8 大屏卡片配置基线种子(灌入 ado_s8_dashboard_cell_config)。
+/// 全局基线:tenant_id=0 / factory_id=0;覆盖 4 个大屏的全部稳定锚点卡片。
+/// 依赖 S8ExceptionTypeSeedData:EXCEPTION_TYPE 绑定的 exception_type_code 必须先存在。
+/// </summary>
+[IncreSeed]
+public class S8DashboardCellConfigSeedData : ISqlSugarEntitySeedData<Entity.S8.AdoS8DashboardCellConfig>
+{
+    public IEnumerable<Entity.S8.AdoS8DashboardCellConfig> HasData()
+    {
+        var ct = DateTime.Parse("2026-04-18 00:00:00");
+        long seq = 1329908200001L;
+        var list = new List<Entity.S8.AdoS8DashboardCellConfig>();
+
+        // ── OVERVIEW 页面 ──
+        // S1-S7 模块卡(7 张,CUSTOM 聚合)
+        var overviewModules = new[] { "S1", "S2", "S3", "S4", "S5", "S6", "S7" };
+        var sort = 100;
+        foreach (var m in overviewModules)
+            list.Add(Custom(seq++, "OVERVIEW", $"OVERVIEW_{m}", $"{m} 模块异常", "OPEN_COUNT", "LAST_24H", sort++, ct));
+
+        // 部门效率聚合
+        list.Add(Aggregate(seq++, "OVERVIEW", "OVERVIEW_BY_DEPT", "部门异常聚合", "ALL", "OPEN_COUNT", "LAST_24H", "OWNER", sort++, ct));
+
+        // 类别异常卡(5 张,按场景聚合)
+        var overviewCats = new[]
+        {
+            ("OVERVIEW_CAT_ORDER_REVIEW",     "订单评审异常"),
+            ("OVERVIEW_CAT_PRODUCT_DESIGN",   "产品设计异常"),
+            ("OVERVIEW_CAT_MATERIAL_PURCHASE","物料采购异常"),
+            ("OVERVIEW_CAT_BODY_PRODUCTION",  "主体生产异常"),
+            ("OVERVIEW_CAT_FINAL_ASSEMBLY",   "总装交付异常"),
+        };
+        foreach (var (code, title) in overviewCats)
+            list.Add(Aggregate(seq++, "OVERVIEW", code, title, "ALL", "FREQUENCY", "LAST_7D", "OWNER", sort++, ct));
+
+        // 整体响应效能 + 状态统计条(CUSTOM)
+        list.Add(Custom(seq++, "OVERVIEW", "OVERVIEW_OVERALL_EFFICIENCY", "整体响应效能", "CLOSE_RATE", "LAST_7D", sort++, ct));
+        list.Add(Custom(seq++, "OVERVIEW", "OVERVIEW_STATUS_BAR",         "状态统计条",   "OPEN_COUNT", "TODAY",   sort++, ct));
+
+        // ── DELIVERY 页面 ──
+        sort = 100;
+        list.Add(Custom(seq++, "DELIVERY", "DELIVERY_S1", "S1 订单模块", "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(Custom(seq++, "DELIVERY", "DELIVERY_S7", "S7 交付模块", "OPEN_COUNT", "LAST_24H", sort++, ct));
+
+        // 异常类型卡(3 张,统计口径 OPEN_COUNT)
+        list.Add(ExceptionType(seq++, "DELIVERY", "DELIVERY_ANOMALY_ORDER_CHANGE",   "订单变更",     "ORDER_CHANGE",     "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(ExceptionType(seq++, "DELIVERY", "DELIVERY_ANOMALY_DELIVERY_DELAY", "交期延迟",     "DELIVERY_DELAY",   "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(ExceptionType(seq++, "DELIVERY", "DELIVERY_ANOMALY_STOCK_PENDING",  "入库待发",     "PENDING_SHIPMENT", "OPEN_COUNT", "LAST_24H", sort++, ct));
+
+        // 异常多维分析卡(3 张:FREQUENCY / AVG_DURATION / CLOSE_RATE)
+        list.Add(ExceptionType(seq++, "DELIVERY", "DELIVERY_CAT_ORDER_CHANGE",   "订单变更多维分析",   "ORDER_CHANGE",     "FREQUENCY",    "LAST_7D", sort++, ct));
+        list.Add(ExceptionType(seq++, "DELIVERY", "DELIVERY_CAT_DELIVERY_DELAY", "交期延迟多维分析",   "DELIVERY_DELAY",   "AVG_DURATION", "LAST_7D", sort++, ct));
+        list.Add(ExceptionType(seq++, "DELIVERY", "DELIVERY_CAT_STOCK_PENDING",  "入库待发多维分析",   "PENDING_SHIPMENT", "CLOSE_RATE",   "LAST_7D", sort++, ct));
+
+        list.Add(Custom(seq++, "DELIVERY", "DELIVERY_STATUS_BAR", "状态统计条", "OPEN_COUNT", "TODAY", sort++, ct));
+
+        // ── PRODUCTION 页面 ──
+        sort = 100;
+        list.Add(Custom(seq++, "PRODUCTION", "PRODUCTION_S2", "S2 排产模块", "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(Custom(seq++, "PRODUCTION", "PRODUCTION_S6", "S6 生产模块", "OPEN_COUNT", "LAST_24H", sort++, ct));
+
+        list.Add(ExceptionType(seq++, "PRODUCTION", "PRODUCTION_ANOMALY_EQUIPMENT_FAULT", "设备异常", "EQUIP_FAULT",       "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(ExceptionType(seq++, "PRODUCTION", "PRODUCTION_ANOMALY_MATERIAL_FAULT",  "物料异常", "MATERIAL_SHORTAGE", "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(ExceptionType(seq++, "PRODUCTION", "PRODUCTION_ANOMALY_QUALITY_FAULT",   "质量异常", "QUALITY_DEFECT",    "OPEN_COUNT", "LAST_24H", sort++, ct));
+
+        list.Add(ExceptionType(seq++, "PRODUCTION", "PRODUCTION_CAT_EQUIPMENT_FAULT", "设备异常多维分析", "EQUIP_FAULT",       "FREQUENCY",    "LAST_7D", sort++, ct));
+        list.Add(ExceptionType(seq++, "PRODUCTION", "PRODUCTION_CAT_MATERIAL_FAULT",  "物料异常多维分析", "MATERIAL_SHORTAGE", "AVG_DURATION", "LAST_7D", sort++, ct));
+        list.Add(ExceptionType(seq++, "PRODUCTION", "PRODUCTION_CAT_QUALITY_FAULT",   "质量异常多维分析", "QUALITY_DEFECT",    "CLOSE_RATE",   "LAST_7D", sort++, ct));
+
+        list.Add(Custom(seq++, "PRODUCTION", "PRODUCTION_STATUS_BAR", "状态统计条", "OPEN_COUNT", "TODAY", sort++, ct));
+
+        // ── SUPPLY 页面 ──
+        sort = 100;
+        list.Add(Custom(seq++, "SUPPLY", "SUPPLY_S3", "S3 供应模块", "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(Custom(seq++, "SUPPLY", "SUPPLY_S4", "S4 入库模块", "OPEN_COUNT", "LAST_24H", sort++, ct));
+        list.Add(Custom(seq++, "SUPPLY", "SUPPLY_S5", "S5 发料模块", "OPEN_COUNT", "LAST_24H", sort++, ct));
+
+        // 供应域 7 类异常:类型卡 + 分析卡
+        var supplyTypes = new[]
+        {
+            ("SUPPLIER_ETA_ISSUE",  "SUPPLIER_REPLY_DELAY",  "供应商回复交期异常"),
+            ("SUPPLIER_SHIP_ISSUE", "SUPPLIER_SHIP_FAULT",   "供应商发货异常"),
+            ("WH_INBOUND_ISSUE",    "WAREHOUSE_RECEIPT",     "仓库收货异常"),
+            ("IQC_ISSUE",           "IQC_INSPECTION",        "IQC 检验异常"),
+            ("WH_PUTAWAY_ISSUE",    "WAREHOUSE_SHELVING",    "仓库上架入库异常"),
+            ("WH_KIT_ISSUE",        "WORK_ORDER_PREPARE",    "仓库工单备料异常"),
+            ("WH_ISSUE_OUT_ISSUE",  "WORK_ORDER_ISSUE",      "仓库工单发料异常"),
+        };
+        foreach (var (typeCode, anchor, title) in supplyTypes)
+            list.Add(ExceptionType(seq++, "SUPPLY", $"SUPPLY_ANOMALY_{anchor}", title, typeCode, "OPEN_COUNT", "LAST_24H", sort++, ct));
+
+        foreach (var (typeCode, anchor, title) in supplyTypes)
+            list.Add(ExceptionType(seq++, "SUPPLY", $"SUPPLY_CAT_{anchor}", $"{title}多维分析", typeCode, "FREQUENCY", "LAST_7D", sort++, ct));
+
+        list.Add(Custom(seq++, "SUPPLY", "SUPPLY_STATUS_BAR", "状态统计条", "OPEN_COUNT", "TODAY", sort++, ct));
+
+        return list;
+    }
+
+    private static Entity.S8.AdoS8DashboardCellConfig Custom(
+        long id, string pageCode, string cellCode, string cellTitle,
+        string statMetric, string timeWindow, int sortNo, DateTime ct) =>
+        new()
+        {
+            Id = id,
+            TenantId = 0,
+            FactoryId = 0,
+            PageCode = pageCode,
+            CellCode = cellCode,
+            CellTitle = cellTitle,
+            BindingType = "CUSTOM",
+            ExceptionTypeCode = null,
+            AggregateScope = null,
+            StatMetric = statMetric,
+            TimeWindow = timeWindow,
+            FilterExpression = null,
+            DeptGroupBy = "OWNER",
+            Enabled = true,
+            SortNo = sortNo,
+            CreatedAt = ct,
+        };
+
+    private static Entity.S8.AdoS8DashboardCellConfig ExceptionType(
+        long id, string pageCode, string cellCode, string cellTitle,
+        string exceptionTypeCode, string statMetric, string timeWindow, int sortNo, DateTime ct) =>
+        new()
+        {
+            Id = id,
+            TenantId = 0,
+            FactoryId = 0,
+            PageCode = pageCode,
+            CellCode = cellCode,
+            CellTitle = cellTitle,
+            BindingType = "EXCEPTION_TYPE",
+            ExceptionTypeCode = exceptionTypeCode,
+            AggregateScope = null,
+            StatMetric = statMetric,
+            TimeWindow = timeWindow,
+            FilterExpression = null,
+            DeptGroupBy = "OWNER",
+            Enabled = true,
+            SortNo = sortNo,
+            CreatedAt = ct,
+        };
+
+    private static Entity.S8.AdoS8DashboardCellConfig Aggregate(
+        long id, string pageCode, string cellCode, string cellTitle,
+        string aggregateScope, string statMetric, string timeWindow, string deptGroupBy,
+        int sortNo, DateTime ct) =>
+        new()
+        {
+            Id = id,
+            TenantId = 0,
+            FactoryId = 0,
+            PageCode = pageCode,
+            CellCode = cellCode,
+            CellTitle = cellTitle,
+            BindingType = "AGGREGATE",
+            ExceptionTypeCode = null,
+            AggregateScope = aggregateScope,
+            StatMetric = statMetric,
+            TimeWindow = timeWindow,
+            FilterExpression = null,
+            DeptGroupBy = deptGroupBy,
+            Enabled = true,
+            SortNo = sortNo,
+            CreatedAt = ct,
+        };
+}

+ 63 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8ExceptionTypeSeedData.cs

@@ -0,0 +1,63 @@
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// S8 异常类型基线种子(灌入 ado_s8_exception_type)。
+/// 13 类异常类型全局基线配置:tenant_id=0 / factory_id=0。
+/// 依赖 S8RolePermissionSeedData(owner_role_code 引用 ROLE_* 编码)。
+/// </summary>
+[IncreSeed]
+public class S8ExceptionTypeSeedData : ISqlSugarEntitySeedData<Entity.S8.AdoS8ExceptionType>
+{
+    public IEnumerable<Entity.S8.AdoS8ExceptionType> HasData()
+    {
+        var ct = DateTime.Parse("2026-04-18 00:00:00");
+        long seq = 1329908100001L;
+
+        return new[]
+        {
+            // ── DELIVERY 域(S1+S7) ──
+            T(seq++, "ORDER_CHANGE",        "订单变更",               "DELIVERY",   "S1S7_DELIVERY",   60,  "ROLE_ORDER_PLANNER",      "MEDIUM", 100, ct),
+            T(seq++, "DELIVERY_DELAY",      "交期延迟",               "DELIVERY",   "S1S7_DELIVERY",   120, "ROLE_ORDER_PLANNER",      "HIGH",   101, ct),
+            T(seq++, "PENDING_SHIPMENT",    "入库待发",               "DELIVERY",   "S1S7_DELIVERY",   240, "ROLE_WH_OUTBOUND",        "MEDIUM", 102, ct),
+
+            // ── PRODUCTION 域(S2+S6) ──
+            T(seq++, "EQUIP_FAULT",         "设备异常",               "PRODUCTION", "S2S6_PRODUCTION", 30,  "ROLE_EQUIP_MAINT",        "HIGH",   200, ct),
+            T(seq++, "MATERIAL_SHORTAGE",   "物料异常",               "PRODUCTION", "S2S6_PRODUCTION", 60,  "ROLE_PRODUCTION_PLANNER", "HIGH",   201, ct),
+            T(seq++, "QUALITY_DEFECT",      "质量异常",               "PRODUCTION", "S2S6_PRODUCTION", 60,  "ROLE_QC",                 "HIGH",   202, ct),
+
+            // ── SUPPLY 域(S3-S5) ──
+            T(seq++, "SUPPLIER_ETA_ISSUE",  "供应商回复交期异常",     "SUPPLY",     "S3S5_SUPPLY",     240, "ROLE_PURCHASER",          "MEDIUM", 300, ct),
+            T(seq++, "SUPPLIER_SHIP_ISSUE", "供应商发货异常",         "SUPPLY",     "S3S5_SUPPLY",     240, "ROLE_PURCHASER",          "MEDIUM", 301, ct),
+            T(seq++, "WH_INBOUND_ISSUE",    "仓库收货异常",           "SUPPLY",     "S3S5_SUPPLY",     120, "ROLE_WH_INBOUND",         "MEDIUM", 302, ct),
+            T(seq++, "IQC_ISSUE",           "IQC 检验异常",           "SUPPLY",     "S3S5_SUPPLY",     120, "ROLE_QC",                 "MEDIUM", 303, ct),
+            T(seq++, "WH_PUTAWAY_ISSUE",    "仓库上架入库异常",       "SUPPLY",     "S3S5_SUPPLY",     120, "ROLE_WH_INBOUND",         "LOW",    304, ct),
+            T(seq++, "WH_KIT_ISSUE",        "仓库工单备料异常",       "SUPPLY",     "S3S5_SUPPLY",     60,  "ROLE_WH_OUTBOUND",        "MEDIUM", 305, ct),
+            T(seq++, "WH_ISSUE_OUT_ISSUE",  "仓库工单发料异常",       "SUPPLY",     "S3S5_SUPPLY",     60,  "ROLE_WH_OUTBOUND",        "MEDIUM", 306, ct),
+        };
+    }
+
+    private static Entity.S8.AdoS8ExceptionType T(
+        long id, string typeCode, string typeName, string domainCode, string sceneCode,
+        int slaMinutes, string ownerRoleCode, string severityDefault, int sortNo, DateTime ct) =>
+        new()
+        {
+            Id = id,
+            TenantId = 0,
+            FactoryId = 0,
+            TypeCode = typeCode,
+            TypeName = typeName,
+            DomainCode = domainCode,
+            SceneCode = sceneCode,
+            SeverityDefault = severityDefault,
+            SlaMinutes = slaMinutes,
+            OwnerRoleCode = ownerRoleCode,
+            EscalateRoleCode = null,
+            StatsMode = "ALL",
+            MobileVisible = true,
+            Icon = null,
+            Enabled = true,
+            SortNo = sortNo,
+            Remark = null,
+            CreatedAt = ct,
+        };
+}

+ 37 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8RolePermissionSeedData.cs

@@ -0,0 +1,37 @@
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// S8 业务角色基线种子(灌入 ado_s8_role_permission_config)。
+/// 作为异常类型配置的责任角色来源:tenant_id=0 / factory_id=0 表示全局默认。
+/// </summary>
+[IncreSeed]
+public class S8RolePermissionSeedData : ISqlSugarEntitySeedData<Entity.S8.AdoS8RolePermissionConfig>
+{
+    public IEnumerable<Entity.S8.AdoS8RolePermissionConfig> HasData()
+    {
+        var ct = DateTime.Parse("2026-04-18 00:00:00");
+        long seq = 1329908000001L;
+
+        return new[]
+        {
+            R(seq++, "ROLE_ORDER_PLANNER",      "s8:exception:read,s8:exception:assign", ct),
+            R(seq++, "ROLE_PRODUCTION_PLANNER", "s8:exception:read,s8:exception:assign", ct),
+            R(seq++, "ROLE_PURCHASER",          "s8:exception:read,s8:exception:assign", ct),
+            R(seq++, "ROLE_WH_INBOUND",         "s8:exception:read,s8:exception:assign", ct),
+            R(seq++, "ROLE_WH_OUTBOUND",        "s8:exception:read,s8:exception:assign", ct),
+            R(seq++, "ROLE_QC",                 "s8:exception:read,s8:exception:assign", ct),
+            R(seq++, "ROLE_EQUIP_MAINT",        "s8:exception:read,s8:exception:assign", ct),
+        };
+    }
+
+    private static Entity.S8.AdoS8RolePermissionConfig R(long id, string roleCode, string perms, DateTime ct) =>
+        new()
+        {
+            Id = id,
+            TenantId = 0,
+            FactoryId = 0,
+            RoleCode = roleCode,
+            PermissionCodes = perms,
+            CreatedAt = ct,
+        };
+}

+ 40 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8SysRoleSeedData.cs

@@ -0,0 +1,40 @@
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// S8 业务角色的 SysRole 侧种子(与 S8RolePermissionSeedData 配套,双写系统角色表)。
+/// 与 AdoS8RolePermissionConfig 通过 Code 字段关联,非 Id。
+/// </summary>
+[IncreSeed]
+public class S8SysRoleSeedData : ISqlSugarEntitySeedData<SysRole>
+{
+    public IEnumerable<SysRole> HasData()
+    {
+        var ct = DateTime.Parse("2026-04-18 00:00:00");
+        long seq = 1329908000101L;
+
+        return new[]
+        {
+            R(seq++, "ROLE_ORDER_PLANNER",      "订单计划员", 800, ct),
+            R(seq++, "ROLE_PRODUCTION_PLANNER", "生产计划员", 801, ct),
+            R(seq++, "ROLE_PURCHASER",          "采购员",     802, ct),
+            R(seq++, "ROLE_WH_INBOUND",         "仓库收货员", 803, ct),
+            R(seq++, "ROLE_WH_OUTBOUND",        "仓库发货员", 804, ct),
+            R(seq++, "ROLE_QC",                 "质检员",     805, ct),
+            R(seq++, "ROLE_EQUIP_MAINT",        "设备维修员", 806, ct),
+        };
+    }
+
+    private static SysRole R(long id, string code, string name, int orderNo, DateTime ct) =>
+        new()
+        {
+            Id = id,
+            TenantId = SqlSugarConst.DefaultTenantId,
+            Name = name,
+            Code = code,
+            OrderNo = orderNo,
+            DataScope = DataScopeEnum.Self,
+            Status = StatusEnum.Enable,
+            Remark = "S8 业务角色(异常协同承接)",
+            CreateTime = ct,
+        };
+}

+ 2 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -740,6 +740,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             (14, "/aidop/s8/config/alert-rules", "aidopS8AlertRulesConfig", "报警规则配置", "/aidop/s8/config/S8AlertRulesPage"),
             (15, "/aidop/s8/config/data-sources", "aidopS8DataSourceConfig", "数据源配置", "/aidop/s8/config/S8DataSourceConfigPage"),
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),
+            (17, "/aidop/s8/config/exception-types", "aidopS8ExceptionTypeConfig", "异常类型配置", "/aidop/s8/config/S8ExceptionTypeConfigPage"),
+            (18, "/aidop/s8/config/dashboard-cells", "aidopS8DashboardCellConfig", "大屏卡片配置", "/aidop/s8/config/S8DashboardCellConfigPage"),
         };
         foreach (var (off, path, name, title, component) in cfg)
         {

+ 115 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardCellConfigService.cs

@@ -0,0 +1,115 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8 大屏卡片配置(ado_s8_dashboard_cell_config)CRUD。
+/// 列表合并全局基线(0/0)与工厂覆盖,同 (pageCode, cellCode) 以工厂记录优先。
+/// </summary>
+public class S8DashboardCellConfigService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8DashboardCellConfig> _rep;
+
+    public S8DashboardCellConfigService(SqlSugarRepository<AdoS8DashboardCellConfig> rep) => _rep = rep;
+
+    public async Task<List<AdoS8DashboardCellConfig>> ListAsync(long tenantId, long factoryId)
+    {
+        var all = await _rep.AsQueryable()
+            .Where(x => (x.TenantId == 0 && x.FactoryId == 0)
+                     || (x.TenantId == tenantId && x.FactoryId == factoryId))
+            .ToListAsync();
+
+        return all
+            .GroupBy(x => (x.PageCode, x.CellCode))
+            .Select(g => g.OrderByDescending(x => x.FactoryId).First())
+            .OrderBy(x => x.PageCode)
+            .ThenBy(x => x.SortNo)
+            .ThenBy(x => x.CellCode)
+            .ToList();
+    }
+
+    public async Task<AdoS8DashboardCellConfig> CreateAsync(AdoS8DashboardCellConfig body)
+    {
+        ValidateAndNormalize(body);
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId
+                && x.PageCode == body.PageCode && x.CellCode == body.CellCode);
+        if (exists) throw new S8BizException("同一工厂下页面与卡片编码组合已存在");
+
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        body.UpdatedAt = null;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8DashboardCellConfig> UpdateAsync(long id, AdoS8DashboardCellConfig body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        ValidateAndNormalize(body);
+        var dup = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId
+                && x.PageCode == body.PageCode && x.CellCode == body.CellCode);
+        if (dup) throw new S8BizException("同一工厂下页面与卡片编码组合已存在");
+
+        body.Id = id;
+        body.CreatedAt = e.CreatedAt;
+        body.UpdatedAt = DateTime.Now;
+        await _rep.UpdateAsync(body);
+        return body;
+    }
+
+    public async Task DeleteAsync(long id) => await _rep.DeleteByIdAsync(id);
+
+    private static void ValidateAndNormalize(AdoS8DashboardCellConfig body)
+    {
+        if (string.IsNullOrWhiteSpace(body.PageCode) || string.IsNullOrWhiteSpace(body.CellCode))
+            throw new S8BizException("页面编码与卡片编码必填");
+        body.PageCode = body.PageCode.Trim();
+        body.CellCode = body.CellCode.Trim();
+        if (body.CellTitle != null) body.CellTitle = body.CellTitle.Trim();
+        if (string.IsNullOrWhiteSpace(body.BindingType))
+            body.BindingType = "CUSTOM";
+        body.BindingType = body.BindingType.Trim().ToUpperInvariant();
+        if (body.BindingType is not ("EXCEPTION_TYPE" or "AGGREGATE" or "CUSTOM"))
+            throw new S8BizException("绑定类型须为 EXCEPTION_TYPE / AGGREGATE / CUSTOM");
+
+        switch (body.BindingType)
+        {
+            case "EXCEPTION_TYPE":
+                if (string.IsNullOrWhiteSpace(body.ExceptionTypeCode))
+                    throw new S8BizException("绑定类型为异常类型时须填写异常类型编码");
+                body.ExceptionTypeCode = body.ExceptionTypeCode.Trim();
+                body.AggregateScope = null;
+                break;
+            case "AGGREGATE":
+                if (string.IsNullOrWhiteSpace(body.AggregateScope))
+                    throw new S8BizException("绑定类型为域聚合时须选择聚合范围");
+                body.AggregateScope = body.AggregateScope!.Trim();
+                body.ExceptionTypeCode = null;
+                break;
+            default:
+                body.ExceptionTypeCode = null;
+                body.AggregateScope = null;
+                break;
+        }
+
+        if (string.IsNullOrWhiteSpace(body.StatMetric)) body.StatMetric = "OPEN_COUNT";
+        body.StatMetric = body.StatMetric.Trim().ToUpperInvariant();
+        if (body.StatMetric is not ("OPEN_COUNT" or "FREQUENCY" or "AVG_DURATION" or "CLOSE_RATE"))
+            throw new S8BizException("统计指标须为 OPEN_COUNT / FREQUENCY / AVG_DURATION / CLOSE_RATE");
+
+        if (string.IsNullOrWhiteSpace(body.TimeWindow)) body.TimeWindow = "LAST_24H";
+        body.TimeWindow = body.TimeWindow.Trim().ToUpperInvariant();
+        if (body.TimeWindow is not ("TODAY" or "LAST_24H" or "LAST_7D" or "LAST_30D"))
+            throw new S8BizException("时间窗须为 TODAY / LAST_24H / LAST_7D / LAST_30D");
+
+        if (string.IsNullOrWhiteSpace(body.DeptGroupBy)) body.DeptGroupBy = "OWNER";
+        body.DeptGroupBy = body.DeptGroupBy.Trim().ToUpperInvariant();
+        if (body.DeptGroupBy is not ("OWNER" or "OCCUR"))
+            throw new S8BizException("部门聚合维度须为 OWNER(责任部门)或 OCCUR(发生部门)");
+
+        if (body.FilterExpression != null && body.FilterExpression.Length > 1000)
+            throw new S8BizException("筛选表达式过长");
+    }
+}

+ 190 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardCellDataService.cs

@@ -0,0 +1,190 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// 大屏卡片数据查询服务:按 (pageCode, cellCode) 读取配置,按 binding_type 分发组装结果。
+/// 配置解析:工厂覆盖优先(tenant+factory 精确匹配)→ 全局基线(tenant=0/factory=0)兜底。
+/// </summary>
+public class S8DashboardCellDataService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8DashboardCellConfig> _cfgRep;
+    private readonly SqlSugarRepository<AdoS8Exception> _exRep;
+    private readonly SqlSugarRepository<SysOrg> _orgRep;
+
+    public S8DashboardCellDataService(
+        SqlSugarRepository<AdoS8DashboardCellConfig> cfgRep,
+        SqlSugarRepository<AdoS8Exception> exRep,
+        SqlSugarRepository<SysOrg> orgRep)
+    {
+        _cfgRep = cfgRep;
+        _exRep = exRep;
+        _orgRep = orgRep;
+    }
+
+    public async Task<AdoS8CellDataDto> GetAsync(AdoS8CellDataQueryDto q)
+    {
+        if (string.IsNullOrWhiteSpace(q.PageCode) || string.IsNullOrWhiteSpace(q.CellCode))
+            throw new S8BizException("pageCode 和 cellCode 必填");
+
+        var cfg = await ResolveConfigAsync(q.TenantId, q.FactoryId, q.PageCode, q.CellCode);
+        if (cfg is null)
+            return new AdoS8CellDataDto
+            {
+                PageCode = q.PageCode,
+                CellCode = q.CellCode,
+                Message = "未配置",
+            };
+        if (!cfg.Enabled)
+            return new AdoS8CellDataDto
+            {
+                PageCode = q.PageCode,
+                CellCode = q.CellCode,
+                BindingType = cfg.BindingType,
+                Title = cfg.CellTitle,
+                StatMetric = cfg.StatMetric,
+                TimeWindow = cfg.TimeWindow,
+                Message = "已禁用",
+            };
+
+        var (from, to) = TimeRange(cfg.TimeWindow);
+        var effectiveDeptGroupBy = !string.IsNullOrWhiteSpace(q.DeptGroupBy) ? q.DeptGroupBy! : cfg.DeptGroupBy;
+
+        // 取事实数据
+        var events = await _exRep.AsQueryable()
+            .Where(e => e.TenantId == q.TenantId && e.FactoryId == q.FactoryId && !e.IsDeleted)
+            .Where(e => e.CreatedAt >= from && e.CreatedAt < to)
+            .Select(e => new EvtRow
+            {
+                Status = e.Status,
+                ExceptionTypeCode = e.ExceptionTypeCode,
+                ModuleCode = e.ModuleCode,
+                SceneCode = e.SceneCode,
+                ResponsibleDeptId = e.ResponsibleDeptId,
+                OccurrenceDeptId = e.OccurrenceDeptId,
+                CreatedAt = e.CreatedAt,
+                ClosedAt = e.ClosedAt,
+            })
+            .ToListAsync();
+
+        // 按 binding_type 过滤
+        IEnumerable<EvtRow> scoped = cfg.BindingType switch
+        {
+            "EXCEPTION_TYPE" => events.Where(e => !string.IsNullOrEmpty(cfg.ExceptionTypeCode) && e.ExceptionTypeCode == cfg.ExceptionTypeCode),
+            "AGGREGATE"      => ApplyAggregateScope(events, cfg.AggregateScope),
+            _                => events, // CUSTOM:不按类型过滤,返回同页范围聚合值供前端参考
+        };
+        var list = scoped.ToList();
+
+        var dto = new AdoS8CellDataDto
+        {
+            CellCode    = cfg.CellCode,
+            PageCode    = cfg.PageCode,
+            Title       = cfg.CellTitle,
+            BindingType = cfg.BindingType,
+            StatMetric  = cfg.StatMetric,
+            TimeWindow  = cfg.TimeWindow,
+            Value       = ComputeValue(list, cfg.StatMetric),
+        };
+
+        // 明细分组:AGGREGATE 按部门;EXCEPTION_TYPE 可选按部门;CUSTOM 跳过
+        if (cfg.BindingType == "AGGREGATE" || cfg.BindingType == "EXCEPTION_TYPE")
+        {
+            dto.Breakdown = await BuildDeptBreakdownAsync(list, effectiveDeptGroupBy, cfg.StatMetric);
+        }
+
+        return dto;
+    }
+
+    private async Task<AdoS8DashboardCellConfig?> ResolveConfigAsync(long tenantId, long factoryId, string pageCode, string cellCode)
+    {
+        var rows = await _cfgRep.AsQueryable()
+            .Where(x => x.PageCode == pageCode && x.CellCode == cellCode
+                && ((x.TenantId == 0 && x.FactoryId == 0)
+                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
+            .ToListAsync();
+        return rows.OrderByDescending(x => x.FactoryId).FirstOrDefault();
+    }
+
+    private static (DateTime from, DateTime to) TimeRange(string window)
+    {
+        var now = DateTime.Now;
+        return window switch
+        {
+            "TODAY"    => (now.Date, now.Date.AddDays(1)),
+            "LAST_24H" => (now.AddHours(-24), now.AddMinutes(1)),
+            "LAST_7D"  => (now.AddDays(-7),   now.AddMinutes(1)),
+            "LAST_30D" => (now.AddDays(-30),  now.AddMinutes(1)),
+            _          => (now.AddHours(-24), now.AddMinutes(1)),
+        };
+    }
+
+    private static IEnumerable<EvtRow> ApplyAggregateScope(List<EvtRow> events, string? scope) => scope switch
+    {
+        "DOMAIN_DELIVERY"   => events.Where(e => e.SceneCode == "S1S7_DELIVERY"),
+        "DOMAIN_PRODUCTION" => events.Where(e => e.SceneCode == "S2S6_PRODUCTION"),
+        "DOMAIN_SUPPLY"     => events.Where(e => e.SceneCode == "S3S5_SUPPLY"),
+        "ALL"               => events,
+        _                   => events,
+    };
+
+    private static double ComputeValue(List<EvtRow> rows, string metric)
+    {
+        if (rows.Count == 0) return 0;
+        return metric switch
+        {
+            "OPEN_COUNT"   => rows.Count(e => e.Status != "CLOSED"),
+            "FREQUENCY"    => rows.Count,
+            "AVG_DURATION" => AvgHours(rows),
+            "CLOSE_RATE"   => Math.Round(rows.Count(e => e.Status == "CLOSED") * 100.0 / rows.Count, 1),
+            _              => rows.Count,
+        };
+    }
+
+    private static double AvgHours(List<EvtRow> rows)
+    {
+        var closed = rows.Where(r => r.ClosedAt.HasValue).ToList();
+        if (closed.Count == 0) return 0;
+        return Math.Round(closed.Average(r => (r.ClosedAt!.Value - r.CreatedAt).TotalHours), 1);
+    }
+
+    private async Task<List<AdoS8CellBreakdownItem>> BuildDeptBreakdownAsync(List<EvtRow> rows, string groupBy, string metric)
+    {
+        Func<EvtRow, long> keySel = groupBy == "OCCUR"
+            ? (EvtRow e) => e.OccurrenceDeptId
+            : (EvtRow e) => e.ResponsibleDeptId;
+
+        var groups = rows.Where(e => keySel(e) > 0).GroupBy(keySel).ToList();
+        if (groups.Count == 0) return new();
+
+        var deptIds = groups.Select(g => g.Key).Distinct().ToList();
+        var names = (await _orgRep.AsQueryable().Where(o => deptIds.Contains(o.Id)).Select(o => new { o.Id, o.Name }).ToListAsync())
+            .ToDictionary(o => o.Id, o => o.Name);
+
+        return groups.Select(g =>
+        {
+            var list = g.ToList();
+            return new AdoS8CellBreakdownItem
+            {
+                Code  = g.Key.ToString(),
+                Label = names.TryGetValue(g.Key, out var n) ? n : $"部门{g.Key}",
+                Value = ComputeValue(list, metric),
+            };
+        })
+        .OrderByDescending(i => i.Value)
+        .ToList();
+    }
+
+    private class EvtRow
+    {
+        public string Status { get; set; } = string.Empty;
+        public string? ExceptionTypeCode { get; set; }
+        public string? ModuleCode { get; set; }
+        public string? SceneCode { get; set; }
+        public long ResponsibleDeptId { get; set; }
+        public long OccurrenceDeptId { get; set; }
+        public DateTime CreatedAt { get; set; }
+        public DateTime? ClosedAt { get; set; }
+    }
+}

+ 87 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionTypeService.cs

@@ -0,0 +1,87 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// 异常类型配置服务(ado_s8_exception_type)。
+/// 读取策略:工厂覆盖优先 → 全局基线(tenant_id=0 / factory_id=0)兜底。
+/// </summary>
+public class S8ExceptionTypeService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8ExceptionType> _rep;
+
+    public S8ExceptionTypeService(SqlSugarRepository<AdoS8ExceptionType> rep) => _rep = rep;
+
+    /// <summary>
+    /// 列表查询:合并全局基线 + 工厂覆盖,同 type_code 以工厂记录优先。
+    /// </summary>
+    public async Task<List<AdoS8ExceptionType>> ListAsync(long tenantId, long factoryId, string? domainCode = null, bool? enabledOnly = null)
+    {
+        var all = await _rep.AsQueryable()
+            .Where(x => (x.TenantId == 0 && x.FactoryId == 0)
+                     || (x.TenantId == tenantId && x.FactoryId == factoryId))
+            .WhereIF(!string.IsNullOrWhiteSpace(domainCode), x => x.DomainCode == domainCode)
+            .WhereIF(enabledOnly == true, x => x.Enabled)
+            .ToListAsync();
+
+        return all
+            .GroupBy(x => x.TypeCode)
+            .Select(g => g.OrderByDescending(x => x.FactoryId).First())
+            .OrderBy(x => x.SortNo)
+            .ThenBy(x => x.TypeCode)
+            .ToList();
+    }
+
+    public async Task<AdoS8ExceptionType?> GetByCodeAsync(long tenantId, long factoryId, string typeCode)
+    {
+        var rows = await _rep.AsQueryable()
+            .Where(x => x.TypeCode == typeCode
+                && ((x.TenantId == 0 && x.FactoryId == 0)
+                    || (x.TenantId == tenantId && x.FactoryId == factoryId)))
+            .ToListAsync();
+        return rows.OrderByDescending(x => x.FactoryId).FirstOrDefault();
+    }
+
+    public async Task<AdoS8ExceptionType> CreateAsync(AdoS8ExceptionType body)
+    {
+        if (string.IsNullOrWhiteSpace(body.TypeCode) || string.IsNullOrWhiteSpace(body.TypeName))
+            throw new S8BizException("类型编码和名称必填");
+        if (string.IsNullOrWhiteSpace(body.DomainCode))
+            throw new S8BizException("所属域必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.TypeCode == body.TypeCode);
+        if (exists) throw new S8BizException("同一工厂下类型编码已存在");
+
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        body.UpdatedAt = null;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8ExceptionType> UpdateAsync(long id, AdoS8ExceptionType body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        if (string.IsNullOrWhiteSpace(body.TypeCode) || string.IsNullOrWhiteSpace(body.TypeName))
+            throw new S8BizException("类型编码和名称必填");
+        var dup = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.TypeCode == body.TypeCode);
+        if (dup) throw new S8BizException("同一工厂下类型编码已存在");
+
+        body.Id = id;
+        body.CreatedAt = e.CreatedAt;
+        body.UpdatedAt = DateTime.Now;
+        await _rep.UpdateAsync(body);
+        return body;
+    }
+
+    public async Task SetEnabledAsync(long id, bool enabled)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        e.Enabled = enabled;
+        e.UpdatedAt = DateTime.Now;
+        await _rep.UpdateAsync(e);
+    }
+
+    public async Task DeleteAsync(long id) => await _rep.DeleteByIdAsync(id);
+}

+ 139 - 33
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

@@ -7,56 +7,162 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 public class S8MonitoringService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
+    private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
+    private readonly SqlSugarRepository<SysOrg> _orgRep;
 
-    public S8MonitoringService(SqlSugarRepository<AdoS8Exception> rep)
+    public S8MonitoringService(
+        SqlSugarRepository<AdoS8Exception> rep,
+        SqlSugarRepository<AdoS8ExceptionType> typeRep,
+        SqlSugarRepository<SysOrg> orgRep)
     {
         _rep = rep;
+        _typeRep = typeRep;
+        _orgRep = orgRep;
     }
 
     /// <summary>
     /// 9宫格数据:S1-S7 订单健康分布 + S8业务类别汇总 + S9部门汇总。
-    /// 当前返回 Mock 数据,待 related_object_code 关联订单就绪后替换为真实查询
+    /// 数据来源 ado_s8_exception 聚合;异常表为空时返回全 0(不再返回 Demo 数据)
     /// </summary>
-    public Task<AdoS8OrderGridDto> GetOrderGridAsync()
+    public async Task<AdoS8OrderGridDto> GetOrderGridAsync(long tenantId = 1, long factoryId = 1)
     {
-        var modules = new List<AdoS8ModuleOrderSummary>
-        {
-            new() { ModuleCode="S1", ModuleLabel="S1·销售评审",  Green=25, Yellow=15, Red=10, Total=50, Frequency=12.5, AvgProcessHours=4.2,  CloseRate=78.0 },
-            new() { ModuleCode="S2", ModuleLabel="S2·计划排产",  Green=30, Yellow=10, Red=5,  Total=45, Frequency=8.3,  AvgProcessHours=3.8,  CloseRate=85.0 },
-            new() { ModuleCode="S3", ModuleLabel="S3·物料套料",  Green=18, Yellow=8,  Red=3,  Total=29, Frequency=6.7,  AvgProcessHours=6.1,  CloseRate=72.0 },
-            new() { ModuleCode="S4", ModuleLabel="S4·采购执行",  Green=22, Yellow=9,  Red=4,  Total=35, Frequency=9.4,  AvgProcessHours=8.3,  CloseRate=68.0 },
-            new() { ModuleCode="S5", ModuleLabel="S5·IQC入库",   Green=28, Yellow=6,  Red=2,  Total=36, Frequency=5.2,  AvgProcessHours=2.9,  CloseRate=91.0 },
-            new() { ModuleCode="S6", ModuleLabel="S6·生产完工",  Green=20, Yellow=12, Red=7,  Total=39, Frequency=11.8, AvgProcessHours=5.7,  CloseRate=71.0 },
-            new() { ModuleCode="S7", ModuleLabel="S7·成品出库",  Green=16, Yellow=8,  Red=6,  Total=30, Frequency=10.1, AvgProcessHours=3.4,  CloseRate=74.0 },
-        };
-        foreach (var m in modules)
-            m.Total = m.Green + m.Yellow + m.Red;
+        var events = await _rep.AsQueryable()
+            .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
+            .Select(e => new
+            {
+                e.ModuleCode,
+                e.Severity,
+                e.Status,
+                e.TimeoutFlag,
+                e.ExceptionTypeCode,
+                e.SceneCode,
+                e.ResponsibleDeptId,
+                e.CreatedAt,
+                e.ClosedAt
+            })
+            .ToListAsync();
 
-        var byCategory = new List<AdoS8CategorySummary>
+        // ── Modules:按 module_code 聚合 ──
+        var modules = S8ModuleCode.All.Select(mc =>
         {
-            new() { Category="订单评审", Total=18, AvgProcessHours=3.5, CloseRate=82.0 },
-            new() { Category="产品设计", Total=9,  AvgProcessHours=6.1, CloseRate=75.0 },
-            new() { Category="材料采购", Total=22, AvgProcessHours=8.3, CloseRate=68.0 },
-            new() { Category="本体生产", Total=31, AvgProcessHours=5.7, CloseRate=71.0 },
-            new() { Category="总装发货", Total=8,  AvgProcessHours=2.9, CloseRate=90.0 },
-        };
+            var rows = events.Where(e => e.ModuleCode == mc).ToList();
+            var unclosed = rows.Where(e => e.Status != "CLOSED").ToList();
+            var red    = unclosed.Count(e => e.Severity == "CRITICAL" || e.Severity == "HIGH");
+            var yellow = unclosed.Count(e => e.Severity == "MEDIUM");
+            var green  = unclosed.Count(e => e.Severity == "LOW");
+            var closed = rows.Count(e => e.Status == "CLOSED");
+            var total  = rows.Count;
+            return new AdoS8ModuleOrderSummary
+            {
+                ModuleCode      = mc,
+                ModuleLabel     = S8ModuleCode.Label(mc),
+                Green           = green,
+                Yellow          = yellow,
+                Red             = red,
+                Total           = total,
+                Frequency       = total,
+                AvgProcessHours = AvgHours(rows.Select(r => (r.CreatedAt, r.ClosedAt))),
+                CloseRate       = total == 0 ? 0 : Math.Round(closed * 100.0 / total, 1),
+            };
+        }).ToList();
 
-        var byDept = new List<AdoS8DeptSummary>
-        {
-            new() { DeptName="市场部",   Total=14, AvgProcessHours=3.2, CloseRate=85.0 },
-            new() { DeptName="研发中心", Total=9,  AvgProcessHours=7.4, CloseRate=72.0 },
-            new() { DeptName="供应链部", Total=22, AvgProcessHours=6.8, CloseRate=65.0 },
-            new() { DeptName="生产部",   Total=31, AvgProcessHours=5.1, CloseRate=74.0 },
-        };
+        // ── ByCategory:按异常类型的 domain_code/type_code 聚合为 5 大业务类别 ──
+        var typeMap = (await _typeRep.AsQueryable()
+                .Where(t => (t.TenantId == 0 && t.FactoryId == 0)
+                         || (t.TenantId == tenantId && t.FactoryId == factoryId))
+                .ToListAsync())
+            .GroupBy(t => t.TypeCode)
+            .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.FactoryId).First());
 
-        return Task.FromResult(new AdoS8OrderGridDto
+        var byCategory = events
+            .Where(e => !string.IsNullOrEmpty(e.ExceptionTypeCode) && typeMap.ContainsKey(e.ExceptionTypeCode!))
+            .GroupBy(e => CategoryOf(typeMap[e.ExceptionTypeCode!]))
+            .Where(g => !string.IsNullOrEmpty(g.Key))
+            .Select(g =>
+            {
+                var total  = g.Count();
+                var closed = g.Count(e => e.Status == "CLOSED");
+                return new AdoS8CategorySummary
+                {
+                    Category        = g.Key!,
+                    Total           = total,
+                    AvgProcessHours = AvgHours(g.Select(e => (e.CreatedAt, e.ClosedAt))),
+                    CloseRate       = total == 0 ? 0 : Math.Round(closed * 100.0 / total, 1),
+                };
+            })
+            .OrderBy(c => CategoryOrder(c.Category))
+            .ToList();
+
+        // ── ByDept:按 ResponsibleDeptId 聚合,JOIN SysOrg 取部门名称 ──
+        var deptIds = events.Select(e => e.ResponsibleDeptId).Where(id => id > 0).Distinct().ToList();
+        var deptNameMap = deptIds.Count == 0
+            ? new Dictionary<long, string>()
+            : (await _orgRep.AsQueryable().Where(o => deptIds.Contains(o.Id)).Select(o => new { o.Id, o.Name }).ToListAsync())
+                .ToDictionary(o => o.Id, o => o.Name);
+
+        var byDept = events
+            .Where(e => e.ResponsibleDeptId > 0)
+            .GroupBy(e => e.ResponsibleDeptId)
+            .Select(g =>
+            {
+                var total  = g.Count();
+                var closed = g.Count(e => e.Status == "CLOSED");
+                return new AdoS8DeptSummary
+                {
+                    DeptName        = deptNameMap.TryGetValue(g.Key, out var n) ? n : $"部门{g.Key}",
+                    Total           = total,
+                    AvgProcessHours = AvgHours(g.Select(e => (e.CreatedAt, e.ClosedAt))),
+                    CloseRate       = total == 0 ? 0 : Math.Round(closed * 100.0 / total, 1),
+                };
+            })
+            .OrderByDescending(d => d.Total)
+            .ToList();
+
+        return new AdoS8OrderGridDto
         {
-            Modules = modules,
+            Modules    = modules,
             ByCategory = byCategory,
-            ByDept = byDept
-        });
+            ByDept     = byDept,
+        };
     }
 
+    private static double AvgHours(IEnumerable<(DateTime createdAt, DateTime? closedAt)> items)
+    {
+        var closed = items.Where(x => x.closedAt.HasValue).ToList();
+        if (closed.Count == 0) return 0;
+        var avg = closed.Average(x => (x.closedAt!.Value - x.createdAt).TotalHours);
+        return Math.Round(avg, 1);
+    }
+
+    /// <summary>异常类型 → 业务类别(与 Overview 页 5 张类别卡对应)。</summary>
+    private static string CategoryOf(AdoS8ExceptionType t) => t.TypeCode switch
+    {
+        "ORDER_CHANGE"        => "订单评审",
+        "DELIVERY_DELAY"      => "总装发货",
+        "PENDING_SHIPMENT"    => "总装发货",
+        "EQUIP_FAULT"         => "本体生产",
+        "MATERIAL_SHORTAGE"   => "本体生产",
+        "QUALITY_DEFECT"      => "本体生产",
+        "SUPPLIER_ETA_ISSUE"  => "材料采购",
+        "SUPPLIER_SHIP_ISSUE" => "材料采购",
+        "WH_INBOUND_ISSUE"    => "材料采购",
+        "IQC_ISSUE"           => "材料采购",
+        "WH_PUTAWAY_ISSUE"    => "材料采购",
+        "WH_KIT_ISSUE"        => "本体生产",
+        "WH_ISSUE_OUT_ISSUE"  => "本体生产",
+        _                     => string.Empty,
+    };
+
+    private static int CategoryOrder(string category) => category switch
+    {
+        "订单评审" => 1,
+        "产品设计" => 2,
+        "材料采购" => 3,
+        "本体生产" => 4,
+        "总装发货" => 5,
+        _         => 99,
+    };
+
     /// <summary>
     /// 异常监控汇总:按 module_code 分组统计红/黄/绿/超时数。
     /// 供综合全景页顶部徽标和模块汇总表使用。