Quellcode durchsuchen

S8前后端骨架&异常面板

YY968XX vor 3 Monaten
Ursprung
Commit
48297c5230
66 geänderte Dateien mit 5078 neuen und 14 gelöschten Zeilen
  1. 20 0
      Web/src/views/aidop/s8/api/s8ConfigApi.ts
  2. 83 0
      Web/src/views/aidop/s8/api/s8DashboardApi.ts
  3. 77 0
      Web/src/views/aidop/s8/api/s8ExceptionApi.ts
  4. 33 0
      Web/src/views/aidop/s8/api/s8ReportApi.ts
  5. 200 0
      Web/src/views/aidop/s8/components/config/S8CrudConfigPage.vue
  6. 47 0
      Web/src/views/aidop/s8/config/S8AlertRulesPage.vue
  7. 46 0
      Web/src/views/aidop/s8/config/S8ConfigHubPage.vue
  8. 39 0
      Web/src/views/aidop/s8/config/S8DataSourceConfigPage.vue
  9. 45 0
      Web/src/views/aidop/s8/config/S8NotificationLayerPage.vue
  10. 51 0
      Web/src/views/aidop/s8/config/S8RolePermissionConfigPage.vue
  11. 35 0
      Web/src/views/aidop/s8/config/S8ScenarioConfigPage.vue
  12. 62 0
      Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue
  13. 247 0
      Web/src/views/aidop/s8/dashboard/S8DashboardConfigDrawer.vue
  14. 815 0
      Web/src/views/aidop/s8/dashboard/S8DashboardPage.vue
  15. 170 0
      Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue
  16. 291 0
      Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue
  17. 147 0
      Web/src/views/aidop/s8/report/S8ManualReportPage.vue
  18. 1 0
      Web/tsconfig.json
  19. 1 1
      ai-dop-platform/docs/AIDOP_MENU_SEED.md
  20. 1 1
      ai-dop-platform/tools/gen_aidop_menu.py
  21. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigAlertRulesController.cs
  22. 46 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDataSourcesController.cs
  23. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLayersController.cs
  24. 48 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigRolesController.cs
  25. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigScenesController.cs
  26. 46 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs
  27. 43 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DashboardController.cs
  28. 20 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DictionariesController.cs
  29. 131 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs
  30. 28 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MasterDataController.cs
  31. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ReportsController.cs
  32. 132 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs
  33. 38 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8AlertRule.cs
  34. 41 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DataSource.cs
  35. 35 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DecisionRecord.cs
  36. 32 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Evidence.cs
  37. 93 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs
  38. 35 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionTimeline.cs
  39. 35 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8NotificationLayer.cs
  40. 29 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8NotificationLog.cs
  41. 17 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ProcessNode.cs
  42. 29 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RolePermissionConfig.cs
  43. 32 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8SceneConfig.cs
  44. 44 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs
  45. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMenuLinkSync.cs
  46. 10 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs
  47. 33 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8Labels.cs
  48. 29 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8StatusRules.cs
  49. 114 1
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs
  50. 47 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8AlertRuleService.cs
  51. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8BizException.cs
  52. 252 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardService.cs
  53. 63 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DataSourceService.cs
  54. 72 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DecisionService.cs
  55. 40 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DictionaryService.cs
  56. 197 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs
  57. 143 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs
  58. 42 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MasterDataAdapter.cs
  59. 47 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerService.cs
  60. 27 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationService.cs
  61. 83 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RoleConfigService.cs
  62. 46 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8SceneConfigService.cs
  63. 141 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs
  64. 74 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs
  65. 38 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs
  66. 13 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

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

@@ -0,0 +1,20 @@
+import service from '/@/utils/request';
+
+function unwrap<T>(res: { data: T }): T {
+	return res.data;
+}
+
+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),
+	update: (endpoint: string, id: number, body: Record<string, unknown>) => service.put(`${endpoint}/${id}`, body).then(unwrap),
+	remove: (endpoint: string, id: number) => service.delete(`${endpoint}/${id}`).then(unwrap),
+	test: (endpoint: string, id: number) => service.post(`${endpoint}/${id}/test`).then(unwrap),
+	scenes: (params?: { tenantId?: number; factoryId?: number }) =>
+		service.get('/api/aidop/s8/config/scenes', { params }).then(unwrap),
+	severities: () => service.get('/api/aidop/s8/dictionaries/S8_EXCEPTION_SEVERITY').then(unwrap),
+	dataSources: (params?: { tenantId?: number; factoryId?: number }) =>
+		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),
+};

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

@@ -0,0 +1,83 @@
+import service from '/@/utils/request';
+
+function unwrap<T>(res: { data: T }): T {
+	return res.data;
+}
+
+export interface S8OverviewData {
+	total: number;
+	pending: number;
+	inProgress: number;
+	timeout: number;
+	closed: number;
+	todayNew: number;
+	critical: number;
+	closureRate: number;
+	avgCycleHours: number;
+}
+
+export interface S8DistItem {
+	key: string;
+	count: number;
+}
+
+export interface S8DeptDistItem extends S8DistItem {
+	deptName: string;
+}
+
+export interface S8ProcessDistItem extends S8DistItem {
+	nodeName: string;
+}
+
+export interface S8Distributions {
+	byStatus: S8DistItem[];
+	byScene: S8DistItem[];
+	bySeverity: S8DistItem[];
+	byDept: S8DeptDistItem[];
+	byOccurrenceDept: S8DeptDistItem[];
+	byProcess: S8ProcessDistItem[];
+	byObject: S8DistItem[];
+}
+
+export interface S8DeptBacklogItem {
+	deptId: number;
+	deptName: string;
+	pending: number;
+	inProgress: number;
+	timeout: number;
+	total: number;
+}
+
+export interface S8QuickException {
+	id: number;
+	exceptionCode: string;
+	title: string;
+	severity: string;
+	status: string;
+	priorityScore: number;
+	timeoutFlag: boolean;
+	slaDeadline: string | null;
+	responsibleDeptId: number;
+}
+
+export const s8DashboardApi = {
+	overview: (params?: { tenantId?: number; factoryId?: number }) =>
+		service.get<S8OverviewData>('/api/aidop/s8/dashboard/overview', { params }).then(unwrap),
+
+	trends: (params?: { tenantId?: number; factoryId?: number; days?: number }) =>
+		service.get<{ date: string; count: number }[]>('/api/aidop/s8/dashboard/trends', { params }).then(unwrap),
+
+	distributions: (params?: { tenantId?: number; factoryId?: number }) =>
+		service.get<S8Distributions>('/api/aidop/s8/dashboard/distributions', { params }).then(unwrap),
+
+	quickExceptions: (params?: { tenantId?: number; factoryId?: number; mode?: string }) =>
+		service.get<S8QuickException[]>('/api/aidop/s8/dashboard/quick-exceptions', { params }).then(unwrap),
+
+	deptBacklog: (params?: { tenantId?: number; factoryId?: number }) =>
+		service.get<S8DeptBacklogItem[]>('/api/aidop/s8/dashboard/dept-backlog', { params }).then(unwrap),
+
+	dimTrends: (params?: { tenantId?: number; factoryId?: number; dim?: string; days?: number }) =>
+		service.get<{ dates: string[]; series: { name: string; data: number[] }[] }>(
+			'/api/aidop/s8/dashboard/dim-trends', { params }
+		).then(unwrap),
+};

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

@@ -0,0 +1,77 @@
+import service from '/@/utils/request';
+
+function unwrap<T>(res: { data: T }): T {
+	return res.data;
+}
+
+export interface S8Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface S8ExceptionRow {
+	id: number;
+	exceptionCode: string;
+	title: string;
+	status: string;
+	statusLabel?: string | null;
+	severity: string;
+	severityLabel?: string | null;
+	priorityScore: number;
+	priorityLevel: string;
+	sceneCode: string;
+	sceneName?: string | null;
+	assigneeId?: number | null;
+	slaDeadline?: string | null;
+	timeoutFlag: boolean;
+	createdAt: string;
+	closedAt?: string | null;
+}
+
+export interface S8DecisionRow {
+	id: number;
+	decisionType: string;
+	decisionMakerName: string;
+	decisionBasis?: string | null;
+	decisionResult?: string | null;
+	decisionRemark?: string | null;
+	decisionTime: string;
+}
+
+export interface S8EvidenceRow {
+	id: number;
+	evidenceType: string;
+	fileName: string;
+	fileUrl: string;
+	sourceSystem?: string | null;
+	uploadedBy?: number | null;
+	uploadedAt: string;
+}
+
+export const s8ExceptionApi = {
+	list: (params: Record<string, unknown>) =>
+		service.get<S8Paged<S8ExceptionRow>>('/api/aidop/s8/exceptions', { params }).then(unwrap),
+	filterOptions: (params?: { tenantId?: number; factoryId?: number }) =>
+		service.get('/api/aidop/s8/exceptions/filter-options', { params }).then(unwrap),
+	detail: (id: number, params?: { tenantId?: number; factoryId?: number }) =>
+		service.get(`/api/aidop/s8/exceptions/${id}`, { params }).then(unwrap),
+	timeline: (id: number) => service.get(`/api/aidop/s8/exceptions/${id}/timeline`).then(unwrap),
+	decisions: (id: number) => service.get(`/api/aidop/s8/exceptions/${id}/decisions`).then(unwrap),
+	evidences: (id: number) => service.get(`/api/aidop/s8/exceptions/${id}/evidences`).then(unwrap),
+	employees: (params?: { factoryRefId?: number }) =>
+		service.get('/api/aidop/s8/master-data/employees', { params }).then(unwrap),
+	claim: (id: number, body: { assigneeId: number; remark?: string }) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/claim`, body).then(unwrap),
+	transfer: (id: number, body: { assigneeId: number; remark?: string }) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/transfer`, body).then(unwrap),
+	upgrade: (id: number, body?: { remark?: string }) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/upgrade`, body ?? {}).then(unwrap),
+	reject: (id: number, body?: { remark?: string }) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/reject`, body ?? {}).then(unwrap),
+	close: (id: number, body?: { remark?: string }) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/close`, body ?? {}).then(unwrap),
+	comment: (id: number, body?: { remark?: string }) =>
+		service.post(`/api/aidop/s8/exceptions/${id}/comment`, body ?? {}).then(unwrap),
+};

+ 33 - 0
Web/src/views/aidop/s8/api/s8ReportApi.ts

@@ -0,0 +1,33 @@
+import service from '/@/utils/request';
+
+function unwrap<T>(res: { data: T }): T {
+	return res.data;
+}
+
+export interface S8ReportCreate {
+	tenantId?: number;
+	factoryId?: number;
+	title: string;
+	description?: string;
+	sceneCode: string;
+	severity?: string;
+	occurrenceDeptId: number;
+	responsibleDeptId: number;
+	reporterId?: number | null;
+}
+
+export interface S8AttachmentCreate {
+	evidenceType?: string;
+	fileName: string;
+	fileUrl: string;
+	sourceSystem?: string;
+	uploadedBy?: number | null;
+}
+
+export const s8ReportApi = {
+	formOptions: (params?: { tenantId?: number; factoryId?: number }) =>
+		service.get('/api/aidop/s8/reports/form-options', { params }).then(unwrap),
+	create: (body: S8ReportCreate) => service.post('/api/aidop/s8/reports', body).then(unwrap),
+	attachments: (id: number, body: S8AttachmentCreate) =>
+		service.post(`/api/aidop/s8/reports/${id}/attachments`, body).then(unwrap),
+};

+ 200 - 0
Web/src/views/aidop/s8/components/config/S8CrudConfigPage.vue

@@ -0,0 +1,200 @@
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '../../../components/AidopDemoShell.vue';
+import { s8ConfigApi } from '../../api/s8ConfigApi';
+
+type OptionItem = { label: string; value: string | number | boolean };
+type FieldType = 'input' | 'textarea' | 'number' | 'switch' | 'select';
+
+interface ConfigField {
+	key: string;
+	label: string;
+	type: FieldType;
+	required?: boolean;
+	optionsKey?: string;
+	options?: OptionItem[];
+	placeholder?: string;
+}
+
+interface ConfigColumn {
+	key: string;
+	label: string;
+	width?: string | number;
+}
+
+const props = defineProps<{
+	title: string;
+	subtitle: string;
+	endpoint: string;
+	idKey?: string;
+	columns: ConfigColumn[];
+	fields: ConfigField[];
+	enableTest?: boolean;
+	buildDefault: () => Record<string, unknown>;
+	loadOptions?: () => Promise<Record<string, OptionItem[]>>;
+}>();
+
+const loading = ref(false);
+const saving = ref(false);
+const dialogVisible = ref(false);
+const rows = ref<Record<string, unknown>[]>([]);
+const editingId = ref<number>(0);
+const dynamicOptions = ref<Record<string, OptionItem[]>>({});
+const form = reactive<Record<string, unknown>>(props.buildDefault());
+
+const idKey = computed(() => props.idKey ?? 'id');
+
+function resetForm() {
+	Object.keys(form).forEach((key) => delete form[key]);
+	Object.assign(form, props.buildDefault());
+}
+
+async function loadData() {
+	loading.value = true;
+	try {
+		rows.value = (await s8ConfigApi.list(props.endpoint, { tenantId: 1, factoryId: 1 })) as Record<string, unknown>[];
+		if (props.loadOptions) {
+			dynamicOptions.value = await props.loadOptions();
+		}
+	} finally {
+		loading.value = false;
+	}
+}
+
+function openCreate() {
+	editingId.value = 0;
+	resetForm();
+	dialogVisible.value = true;
+}
+
+function openEdit(row: Record<string, unknown>) {
+	editingId.value = Number(row[idKey.value] || 0);
+	resetForm();
+	Object.assign(form, row);
+	dialogVisible.value = true;
+}
+
+function fieldOptions(field: ConfigField) {
+	if (field.options?.length) return field.options;
+	if (field.optionsKey) return dynamicOptions.value[field.optionsKey] ?? [];
+	return [];
+}
+
+function validateForm() {
+	for (const field of props.fields) {
+		if (!field.required) continue;
+		const value = form[field.key];
+		if (value === undefined || value === null || value === '') {
+			ElMessage.warning(`${field.label}不能为空`);
+			return false;
+		}
+	}
+	return true;
+}
+
+async function save() {
+	if (!validateForm()) return;
+	saving.value = true;
+	try {
+		const payload = { ...form, tenantId: 1, factoryId: 1 };
+		if (editingId.value > 0) {
+			await s8ConfigApi.update(props.endpoint, editingId.value, payload);
+			ElMessage.success('更新成功');
+		} else {
+			await s8ConfigApi.create(props.endpoint, payload);
+			ElMessage.success('新增成功');
+		}
+		dialogVisible.value = false;
+		await loadData();
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function removeRow(row: Record<string, unknown>) {
+	await ElMessageBox.confirm('确认删除当前记录?', '提示', { type: 'warning' });
+	await s8ConfigApi.remove(props.endpoint, Number(row[idKey.value]));
+	ElMessage.success('删除成功');
+	await loadData();
+}
+
+async function testRow(row: Record<string, unknown>) {
+	const res = (await s8ConfigApi.test(props.endpoint, Number(row[idKey.value]))) as { success?: boolean; message?: string };
+	ElMessage[res.success ? 'success' : 'warning'](res.message || '测试完成');
+	await loadData();
+}
+
+onMounted(() => {
+	void loadData();
+});
+</script>
+
+<template>
+	<AidopDemoShell :title="title" :subtitle="subtitle">
+		<div class="toolbar">
+			<el-button type="primary" @click="openCreate">新增</el-button>
+			<el-button @click="loadData">刷新</el-button>
+			<slot name="toolbar-extra" :reload="loadData" />
+		</div>
+
+		<el-table :data="rows" v-loading="loading" border stripe>
+			<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :width="column.width" show-overflow-tooltip />
+			<el-table-column label="操作" width="220" fixed="right">
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+					<el-button v-if="enableTest" link type="warning" @click="testRow(row)">测试</el-button>
+					<el-button link type="danger" @click="removeRow(row)">删除</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<el-dialog v-model="dialogVisible" :title="editingId > 0 ? '编辑配置' : '新增配置'" width="720px">
+			<el-form label-width="120px">
+				<el-form-item v-for="field in fields" :key="field.key" :label="field.label" :required="field.required">
+					<el-input
+						v-if="field.type === 'input'"
+						v-model="form[field.key]"
+						:placeholder="field.placeholder || `请输入${field.label}`"
+					/>
+					<el-input
+						v-else-if="field.type === 'textarea'"
+						v-model="form[field.key]"
+						type="textarea"
+						:rows="3"
+						:placeholder="field.placeholder || `请输入${field.label}`"
+					/>
+					<el-input-number
+						v-else-if="field.type === 'number'"
+						v-model="form[field.key]"
+						:min="0"
+						style="width: 100%"
+					/>
+					<el-switch v-else-if="field.type === 'switch'" v-model="form[field.key]" />
+					<el-select
+						v-else-if="field.type === 'select'"
+						v-model="form[field.key]"
+						clearable
+						style="width: 100%"
+						:placeholder="field.placeholder || `请选择${field.label}`"
+					>
+						<el-option v-for="option in fieldOptions(field)" :key="String(option.value)" :label="option.label" :value="option.value" />
+					</el-select>
+				</el-form-item>
+			</el-form>
+
+			<template #footer>
+				<el-button @click="dialogVisible = false">取消</el-button>
+				<el-button type="primary" :loading="saving" @click="save">保存</el-button>
+			</template>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
+<style scoped>
+.toolbar {
+	display: flex;
+	gap: 12px;
+	margin-bottom: 12px;
+}
+</style>

+ 47 - 0
Web/src/views/aidop/s8/config/S8AlertRulesPage.vue

@@ -0,0 +1,47 @@
+<script setup lang="ts" name="aidopS8AlertRulesConfig">
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import { s8ConfigApi } from '../api/s8ConfigApi';
+
+const columns = [
+	{ key: 'ruleCode', label: '规则编码', width: 160 },
+	{ key: 'sceneCode', label: '场景编码', width: 140 },
+	{ key: 'severity', label: '严重度', width: 120 },
+	{ key: 'triggerCondition', label: '触发条件' },
+	{ key: 'thresholdVal', label: '阈值', width: 140 },
+];
+
+const fields = [
+	{ key: 'ruleCode', label: '规则编码', type: 'input', required: true },
+	{ key: 'sceneCode', label: '场景编码', type: 'select', required: true, optionsKey: 'scenes' },
+	{ key: 'severity', label: '严重度', type: 'select', required: true, optionsKey: 'severities' },
+	{ key: 'triggerCondition', label: '触发条件', type: 'input', required: true },
+	{ key: 'thresholdVal', label: '阈值', type: 'input' },
+	{ key: 'timeWindow', label: '时间窗口', type: 'input' },
+] as const;
+
+const buildDefault = () => ({
+	ruleCode: '',
+	sceneCode: '',
+	severity: 'MEDIUM',
+	triggerCondition: '',
+	thresholdVal: '',
+	timeWindow: '',
+});
+
+async function loadOptions() {
+	const [scenes, severities] = await Promise.all([s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 }), s8ConfigApi.severities()]);
+	return { scenes: scenes.map((item: any) => ({ label: item.sceneName, value: item.sceneCode })), severities };
+}
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="报警规则配置"
+		subtitle="S8 / 配置 / 报警规则"
+		endpoint="/api/aidop/s8/config/alert-rules"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+		:load-options="loadOptions"
+	/>
+</template>

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

@@ -0,0 +1,46 @@
+<template>
+	<AidopDemoShell title="配置中心" subtitle="S8 异常协同 / 配置子页入口">
+		<el-row :gutter="12">
+			<el-col v-for="c in cards" :key="c.path" :span="8">
+				<el-card shadow="hover" class="cfg-card" @click="go(c.path)">
+					<div class="cfg-card__title">{{ c.title }}</div>
+					<div class="cfg-card__desc">{{ c.desc }}</div>
+				</el-card>
+			</el-col>
+		</el-row>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS8ConfigHub">
+import { useRouter } from 'vue-router';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+
+const router = useRouter();
+const cards = [
+	{ path: '/aidop/s8/config/scenes', 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: '阈值与触发条件' },
+	{ path: '/aidop/s8/config/data-sources', title: '数据源', desc: '连接与启用' },
+	{ path: '/aidop/s8/config/watch-rules', title: '监视规则', desc: '轮询与表达式' },
+];
+
+function go(path: string) {
+	router.push(path);
+}
+</script>
+
+<style scoped>
+.cfg-card {
+	cursor: pointer;
+	min-height: 96px;
+}
+.cfg-card__title {
+	font-weight: 600;
+	margin-bottom: 8px;
+}
+.cfg-card__desc {
+	font-size: 13px;
+	color: var(--el-text-color-secondary);
+}
+</style>

+ 39 - 0
Web/src/views/aidop/s8/config/S8DataSourceConfigPage.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts" name="aidopS8DataSourceConfig">
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+
+const columns = [
+	{ key: 'dataSourceCode', label: '数据源编码', width: 180 },
+	{ key: 'type', label: '类型', width: 120 },
+	{ key: 'endpoint', label: '连接地址' },
+	{ key: 'enabled', label: '启用', width: 90 },
+	{ key: 'lastCheckStatus', label: '最近检测', width: 180 },
+];
+
+const fields = [
+	{ key: 'dataSourceCode', label: '数据源编码', type: 'input', required: true },
+	{ key: 'type', label: '类型', type: 'select', required: true, options: [{ label: 'HTTP', value: 'HTTP' }, { label: 'SQL', value: 'SQL' }, { label: 'MQ', value: 'MQ' }] },
+	{ key: 'endpoint', label: '连接地址', type: 'input', required: true },
+	{ key: 'authType', label: '认证方式', type: 'input' },
+	{ key: 'enabled', label: '启用', type: 'switch' },
+] as const;
+
+const buildDefault = () => ({
+	dataSourceCode: '',
+	type: 'HTTP',
+	endpoint: '',
+	authType: '',
+	enabled: true,
+});
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="数据源配置"
+		subtitle="S8 / 配置 / 数据源"
+		endpoint="/api/aidop/s8/config/data-sources"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+		:enable-test="true"
+	/>
+</template>

+ 45 - 0
Web/src/views/aidop/s8/config/S8NotificationLayerPage.vue

@@ -0,0 +1,45 @@
+<script setup lang="ts" name="aidopS8NotificationLayerConfig">
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import { s8ConfigApi } from '../api/s8ConfigApi';
+
+const columns = [
+	{ key: 'sceneCode', label: '场景编码', width: 140 },
+	{ key: 'severity', label: '严重度', width: 120 },
+	{ key: 'levelCode', label: '层级', width: 120 },
+	{ key: 'notifyChannel', label: '通知渠道', width: 120 },
+	{ key: 'targetRoleIds', label: '目标角色' },
+];
+
+const fields = [
+	{ key: 'sceneCode', label: '场景编码', type: 'select', required: true, optionsKey: 'scenes' },
+	{ key: 'severity', label: '严重度', type: 'select', required: true, optionsKey: 'severities' },
+	{ key: 'levelCode', label: '层级', type: 'input', required: true },
+	{ key: 'notifyChannel', label: '通知渠道', type: 'input' },
+	{ key: 'targetRoleIds', label: '目标角色', type: 'textarea' },
+] as const;
+
+const buildDefault = () => ({
+	sceneCode: '',
+	severity: 'MEDIUM',
+	levelCode: '',
+	notifyChannel: 'log',
+	targetRoleIds: '',
+});
+
+async function loadOptions() {
+	const [scenes, severities] = await Promise.all([s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 }), s8ConfigApi.severities()]);
+	return { scenes: scenes.map((item: any) => ({ label: item.sceneName, value: item.sceneCode })), severities };
+}
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="通知分层配置"
+		subtitle="S8 / 配置 / 通知分层"
+		endpoint="/api/aidop/s8/config/notification-layers"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+		:load-options="loadOptions"
+	/>
+</template>

+ 51 - 0
Web/src/views/aidop/s8/config/S8RolePermissionConfigPage.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts" name="aidopS8RolePermissionConfig">
+import { ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import { s8ConfigApi } from '../api/s8ConfigApi';
+
+const columns = [
+	{ key: 'roleCode', label: '角色编码', width: 180 },
+	{ key: 'permissionCodes', label: '权限编码' },
+];
+
+const fields = [
+	{ key: 'roleCode', label: '角色编码', type: 'input', required: true },
+	{ key: 'permissionCodes', label: '权限编码', type: 'textarea', required: true, placeholder: '多个权限用逗号分隔' },
+] as const;
+
+const buildDefault = () => ({
+	roleCode: '',
+	permissionCodes: '',
+});
+
+const syncing = ref(false);
+
+async function syncFromSys(reload: () => void) {
+	syncing.value = true;
+	try {
+		const res = (await s8ConfigApi.importRolesFromSys({ tenantId: 1, factoryId: 1 })) as { imported: number };
+		ElMessage.success(`同步完成,新增 ${res.imported} 条角色`);
+		reload();
+	} catch {
+		ElMessage.error('同步失败,请检查服务连接');
+	} finally {
+		syncing.value = false;
+	}
+}
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="角色权限配置"
+		subtitle="S8 / 配置 / 角色权限"
+		endpoint="/api/aidop/s8/config/roles"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+	>
+		<template #toolbar-extra="{ reload }">
+			<el-button :loading="syncing" @click="syncFromSys(reload)">同步系统角色</el-button>
+		</template>
+	</S8CrudConfigPage>
+</template>

+ 35 - 0
Web/src/views/aidop/s8/config/S8ScenarioConfigPage.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts" name="aidopS8ScenarioConfig">
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+
+const columns = [
+	{ key: 'sceneCode', label: '场景编码', width: 160 },
+	{ key: 'sceneName', label: '场景名称', width: 220 },
+	{ key: 'enabled', label: '启用', width: 90 },
+	{ key: 'sortNo', label: '排序', width: 90 },
+];
+
+const fields = [
+	{ key: 'sceneCode', label: '场景编码', type: 'input', required: true },
+	{ key: 'sceneName', label: '场景名称', type: 'input', required: true },
+	{ key: 'enabled', label: '启用', type: 'switch' },
+	{ key: 'sortNo', label: '排序', type: 'number' },
+] as const;
+
+const buildDefault = () => ({
+	sceneCode: '',
+	sceneName: '',
+	enabled: true,
+	sortNo: 0,
+});
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="场景基础配置"
+		subtitle="S8 / 配置 / 场景"
+		endpoint="/api/aidop/s8/config/scenes"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+	/>
+</template>

+ 62 - 0
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts" name="aidopS8WatchRuleConfig">
+import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import { s8ConfigApi } from '../api/s8ConfigApi';
+
+const columns = [
+	{ key: 'ruleCode', label: '规则编码', width: 160 },
+	{ key: 'sceneCode', label: '场景编码', width: 140 },
+	{ key: 'dataSourceId', label: '数据源', width: 120 },
+	{ key: 'watchObjectType', label: '监视对象', width: 140 },
+	{ key: 'severity', label: '严重度', width: 120 },
+	{ key: 'pollIntervalSeconds', label: '轮询间隔', width: 120 },
+	{ key: 'enabled', label: '启用', width: 90 },
+	];
+
+const fields = [
+	{ key: 'ruleCode', label: '规则编码', type: 'input', required: true },
+	{ key: 'sceneCode', label: '场景编码', type: 'select', required: true, optionsKey: 'scenes' },
+	{ key: 'dataSourceId', label: '数据源', type: 'select', required: true, optionsKey: 'dataSources' },
+	{ key: 'watchObjectType', label: '监视对象', type: 'input', required: true },
+	{ key: 'expression', label: '表达式', type: 'textarea' },
+	{ key: 'severity', label: '严重度', type: 'select', required: true, optionsKey: 'severities' },
+	{ key: 'pollIntervalSeconds', label: '轮询间隔(秒)', type: 'number', required: true },
+	{ key: 'enabled', label: '启用', type: 'switch' },
+] as const;
+
+const buildDefault = () => ({
+	ruleCode: '',
+	sceneCode: '',
+	dataSourceId: undefined,
+	watchObjectType: '',
+	expression: '',
+	severity: 'MEDIUM',
+	pollIntervalSeconds: 300,
+	enabled: true,
+});
+
+async function loadOptions() {
+	const [scenes, severities, dataSources] = await Promise.all([
+		s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 }),
+		s8ConfigApi.severities(),
+		s8ConfigApi.dataSources({ tenantId: 1, factoryId: 1 }),
+	]);
+	return {
+		scenes: scenes.map((item: any) => ({ label: item.sceneName, value: item.sceneCode })),
+		severities,
+		dataSources: dataSources.map((item: any) => ({ label: `${item.dataSourceCode}${item.enabled ? '' : '(未启用)'}`, value: item.id })),
+	};
+}
+</script>
+
+<template>
+	<S8CrudConfigPage
+		title="监视规则配置"
+		subtitle="S8 / 配置 / 监视规则"
+		endpoint="/api/aidop/s8/config/watch-rules"
+		:columns="columns"
+		:fields="fields"
+		:build-default="buildDefault"
+		:load-options="loadOptions"
+		:enable-test="true"
+	/>
+</template>

+ 247 - 0
Web/src/views/aidop/s8/dashboard/S8DashboardConfigDrawer.vue

@@ -0,0 +1,247 @@
+<script setup lang="ts">
+import { reactive, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+
+type PanelKey = 'object' | 'process' | 'occurrence' | 'responsible';
+type TrendDays = 7 | 14 | 30;
+
+interface DashboardPreferences {
+	defaultPanel: PanelKey;
+	trendDays: TrendDays;
+	sections: {
+		showRefreshBar: boolean;
+		showFilterBar: boolean;
+		showKpiSection: boolean;
+		showTrendChart: boolean;
+		showDetailTable: boolean;
+	};
+	visibleKpiKeys: string[];
+}
+
+interface KpiOption {
+	key: string;
+	label: string;
+	color: string;
+}
+
+const props = defineProps<{
+	modelValue: boolean;
+	config: DashboardPreferences;
+	kpiOptions: KpiOption[];
+}>();
+
+const emit = defineEmits<{
+	'update:modelValue': [value: boolean];
+	save: [value: DashboardPreferences];
+	reset: [];
+}>();
+
+const panelOptions: { label: string; value: PanelKey }[] = [
+	{ label: '对象总览', value: 'object' },
+	{ label: '流程环节', value: 'process' },
+	{ label: '发生部门', value: 'occurrence' },
+	{ label: '处理部门', value: 'responsible' },
+];
+
+const trendDayOptions: TrendDays[] = [7, 14, 30];
+
+function cloneConfig(config: DashboardPreferences): DashboardPreferences {
+	return {
+		defaultPanel: config.defaultPanel,
+		trendDays: config.trendDays,
+		sections: { ...config.sections },
+		visibleKpiKeys: [...config.visibleKpiKeys],
+	};
+}
+
+const localConfig = reactive<DashboardPreferences>(cloneConfig(props.config));
+
+function syncLocalConfig(config: DashboardPreferences) {
+	Object.assign(localConfig, cloneConfig(config));
+}
+
+watch(
+	() => props.modelValue,
+	(open) => {
+		if (open) syncLocalConfig(props.config);
+	}
+);
+
+watch(
+	() => props.config,
+	(config) => {
+		if (props.modelValue) syncLocalConfig(config);
+	},
+	{ deep: true }
+);
+
+function closeDrawer() {
+	emit('update:modelValue', false);
+}
+
+function saveConfig() {
+	if (!localConfig.visibleKpiKeys.length) {
+		ElMessage.warning('至少保留 1 个 KPI 卡片');
+		return;
+	}
+	emit('save', cloneConfig(localConfig));
+	closeDrawer();
+}
+
+function resetConfig() {
+	emit('reset');
+}
+</script>
+
+<template>
+	<el-drawer
+		:model-value="modelValue"
+		title="面板配置"
+		direction="rtl"
+		size="420px"
+		destroy-on-close
+		@close="closeDrawer"
+	>
+		<div class="s8-config">
+			<section class="s8-config__section">
+				<div class="s8-config__title">默认视角</div>
+				<el-radio-group v-model="localConfig.defaultPanel" class="s8-config__radio-group">
+					<el-radio-button v-for="item in panelOptions" :key="item.value" :label="item.value">
+						{{ item.label }}
+					</el-radio-button>
+				</el-radio-group>
+			</section>
+
+			<section class="s8-config__section">
+				<div class="s8-config__title">趋势范围</div>
+				<el-radio-group v-model="localConfig.trendDays" class="s8-config__radio-group">
+					<el-radio-button v-for="day in trendDayOptions" :key="day" :label="day">
+						{{ day }} 天
+					</el-radio-button>
+				</el-radio-group>
+			</section>
+
+			<section class="s8-config__section">
+				<div class="s8-config__title">页面区块</div>
+				<div class="s8-config__switch-list">
+					<div class="s8-config__switch-item">
+						<span>刷新栏</span>
+						<el-switch v-model="localConfig.sections.showRefreshBar" />
+					</div>
+					<div class="s8-config__switch-item">
+						<span>筛选栏</span>
+						<el-switch v-model="localConfig.sections.showFilterBar" />
+					</div>
+					<div class="s8-config__switch-item">
+						<span>KPI 卡片</span>
+						<el-switch v-model="localConfig.sections.showKpiSection" />
+					</div>
+					<div class="s8-config__switch-item">
+						<span>右侧趋势图</span>
+						<el-switch v-model="localConfig.sections.showTrendChart" />
+					</div>
+					<div class="s8-config__switch-item">
+						<span>底部明细表</span>
+						<el-switch v-model="localConfig.sections.showDetailTable" />
+					</div>
+				</div>
+			</section>
+
+			<section class="s8-config__section">
+				<div class="s8-config__title">KPI 卡片可见项</div>
+				<el-checkbox-group v-model="localConfig.visibleKpiKeys" class="s8-config__kpi-list">
+					<el-checkbox
+						v-for="item in kpiOptions"
+						:key="item.key"
+						:label="item.key"
+						border
+						class="s8-config__kpi-item"
+					>
+						<span class="s8-config__kpi-dot" :style="{ backgroundColor: item.color }"></span>
+						{{ item.label }}
+					</el-checkbox>
+				</el-checkbox-group>
+			</section>
+		</div>
+
+		<template #footer>
+			<div class="s8-config__footer">
+				<el-button @click="resetConfig">恢复默认</el-button>
+				<el-button @click="closeDrawer">取消</el-button>
+				<el-button type="primary" @click="saveConfig">保存并应用</el-button>
+			</div>
+		</template>
+	</el-drawer>
+</template>
+
+<style scoped>
+.s8-config {
+	display: flex;
+	flex-direction: column;
+	gap: 18px;
+	padding: 4px 20px 16px;
+}
+
+.s8-config__section {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.s8-config__title {
+	font-size: 13px;
+	font-weight: 700;
+	color: #0f172a;
+}
+
+.s8-config__radio-group {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 8px;
+}
+
+.s8-config__radio-group :deep(.el-radio-button__inner) {
+	width: 100%;
+}
+
+.s8-config__switch-list {
+	display: grid;
+	gap: 10px;
+}
+
+.s8-config__switch-item {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding: 10px 12px;
+	border: 1px solid #e2e8f0;
+	border-radius: 10px;
+	background: #f8fafc;
+	color: #334155;
+}
+
+.s8-config__kpi-list {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 10px;
+}
+
+.s8-config__kpi-item {
+	margin-right: 0;
+}
+
+.s8-config__kpi-dot {
+	display: inline-flex;
+	width: 8px;
+	height: 8px;
+	margin-right: 6px;
+	border-radius: 999px;
+}
+
+.s8-config__footer {
+	display: flex;
+	justify-content: flex-end;
+	gap: 8px;
+	width: 100%;
+}
+</style>

+ 815 - 0
Web/src/views/aidop/s8/dashboard/S8DashboardPage.vue

@@ -0,0 +1,815 @@
+<script setup lang="ts" name="aidopS8Dashboard">
+import * as echarts from 'echarts';
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import S8DashboardConfigDrawer from './S8DashboardConfigDrawer.vue';
+import {
+	s8DashboardApi,
+	type S8DeptBacklogItem,
+	type S8Distributions,
+	type S8OverviewData,
+} from '../api/s8DashboardApi';
+import service from '/@/utils/request';
+import { Local } from '/@/utils/storage';
+
+// ─── 路由 ────────────────────────────────────────────────────
+const router = useRouter();
+
+type PanelKey = 'object' | 'process' | 'occurrence' | 'responsible';
+type TrendDays = 7 | 14 | 30;
+
+interface PanelFilterState {
+	severity: string;
+	dateStart: Date | null;
+	dateEnd: Date | null;
+	extra: string;
+}
+
+interface DashboardPreferences {
+	defaultPanel: PanelKey;
+	trendDays: TrendDays;
+	sections: {
+		showRefreshBar: boolean;
+		showFilterBar: boolean;
+		showKpiSection: boolean;
+		showTrendChart: boolean;
+		showDetailTable: boolean;
+	};
+	visibleKpiKeys: string[];
+}
+
+const DASHBOARD_PREFERENCE_KEY = 'aidop-s8-dashboard-preferences';
+
+// ─── 面板 / KPI 元信息 ───────────────────────────────────────
+const panels = [
+	{ key: 'object' as PanelKey, label: '对象总览' },
+	{ key: 'process' as PanelKey, label: '流程环节' },
+	{ key: 'occurrence' as PanelKey, label: '发生部门' },
+	{ key: 'responsible' as PanelKey, label: '处理部门' },
+];
+
+const kpiCards = [
+	{ key: 'total', label: '总数', suffix: '', color: '#6366f1' },
+	{ key: 'pending', label: '待处理', suffix: '', color: '#f59e0b' },
+	{ key: 'inProgress', label: '处理中', suffix: '', color: '#3b82f6' },
+	{ key: 'timeout', label: '超时', suffix: '', color: '#ef4444' },
+	{ key: 'closed', label: '已闭环', suffix: '', color: '#10b981' },
+	{ key: 'todayNew', label: '今日新增', suffix: '', color: '#8b5cf6' },
+	{ key: 'closureRate', label: '闭环率', suffix: '%', color: '#10b981' },
+	{ key: 'avgCycleHours', label: '均处理周期', suffix: 'h', color: '#64748b' },
+];
+
+const allKpiKeys = kpiCards.map((card) => card.key);
+
+function createDefaultDateRange() {
+	const end = new Date();
+	end.setHours(0, 0, 0, 0);
+	const start = new Date(end);
+	start.setDate(end.getDate() - 7);
+	return { start, end };
+}
+
+function createDefaultPanelFilter(): PanelFilterState {
+	const { start, end } = createDefaultDateRange();
+	return {
+		severity: '',
+		dateStart: start,
+		dateEnd: end,
+		extra: '',
+	};
+}
+
+function createDefaultPanelFilters(): Record<PanelKey, PanelFilterState> {
+	return {
+		object: createDefaultPanelFilter(),
+		process: createDefaultPanelFilter(),
+		occurrence: createDefaultPanelFilter(),
+		responsible: createDefaultPanelFilter(),
+	};
+}
+
+const DEFAULT_PREFERENCES: DashboardPreferences = {
+	defaultPanel: 'object',
+	trendDays: 14,
+	sections: {
+		showRefreshBar: true,
+		showFilterBar: true,
+		showKpiSection: true,
+		showTrendChart: true,
+		showDetailTable: true,
+	},
+	visibleKpiKeys: [...allKpiKeys],
+};
+
+function clonePreferences(source: DashboardPreferences): DashboardPreferences {
+	return {
+		defaultPanel: source.defaultPanel,
+		trendDays: source.trendDays,
+		sections: { ...source.sections },
+		visibleKpiKeys: [...source.visibleKpiKeys],
+	};
+}
+
+function normalizePreferences(raw: unknown): DashboardPreferences {
+	const source = (raw && typeof raw === 'object' ? raw : {}) as Partial<DashboardPreferences>;
+	const panelKeys = new Set(panels.map((item) => item.key));
+	const trendDays = source.trendDays;
+	const visibleKpiKeys = Array.isArray(source.visibleKpiKeys)
+		? source.visibleKpiKeys.filter((key): key is string => typeof key === 'string' && allKpiKeys.includes(key))
+		: [];
+
+	return {
+		defaultPanel: panelKeys.has(source.defaultPanel as PanelKey) ? (source.defaultPanel as PanelKey) : DEFAULT_PREFERENCES.defaultPanel,
+		trendDays: trendDays === 7 || trendDays === 14 || trendDays === 30 ? trendDays : DEFAULT_PREFERENCES.trendDays,
+		sections: {
+			showRefreshBar: source.sections?.showRefreshBar ?? DEFAULT_PREFERENCES.sections.showRefreshBar,
+			showFilterBar: source.sections?.showFilterBar ?? DEFAULT_PREFERENCES.sections.showFilterBar,
+			showKpiSection: source.sections?.showKpiSection ?? DEFAULT_PREFERENCES.sections.showKpiSection,
+			showTrendChart: source.sections?.showTrendChart ?? DEFAULT_PREFERENCES.sections.showTrendChart,
+			showDetailTable: source.sections?.showDetailTable ?? DEFAULT_PREFERENCES.sections.showDetailTable,
+		},
+		visibleKpiKeys: visibleKpiKeys.length ? visibleKpiKeys : [...DEFAULT_PREFERENCES.visibleKpiKeys],
+	};
+}
+
+function savePreferences(config: DashboardPreferences) {
+	Local.set(DASHBOARD_PREFERENCE_KEY, config);
+}
+
+const preferences = reactive<DashboardPreferences>(normalizePreferences(Local.get(DASHBOARD_PREFERENCE_KEY)));
+
+// ─── 状态 ────────────────────────────────────────────────────
+const loading = ref(false);
+const lastRefreshedAt = ref('');
+const configDrawerVisible = ref(false);
+
+// 筛选条件
+const panelFilters = reactive<Record<PanelKey, PanelFilterState>>(createDefaultPanelFilters());
+const activePanel = ref<PanelKey>(preferences.defaultPanel);
+const currentPanelFilter = computed(() => panelFilters[activePanel.value]);
+
+// 数据
+const overview = reactive<S8OverviewData>({
+	total: 0, pending: 0, inProgress: 0, timeout: 0, closed: 0,
+	todayNew: 0, critical: 0, closureRate: 0, avgCycleHours: 0,
+});
+const distributions = ref<S8Distributions | null>(null);
+const deptBacklog = ref<S8DeptBacklogItem[]>([]);
+const dimTrendsData = ref<{ dates: string[]; series: { name: string; data: number[] }[] }>({ dates: [], series: [] });
+
+// 明细表
+const detailList = ref<any[]>([]);
+const detailTotal = ref(0);
+const detailPage = ref(1);
+const detailPageSize = ref(20);
+const chartDrillValue = ref('');
+
+// ─── ECharts ─────────────────────────────────────────────────
+const mainChartRef = ref<HTMLElement | null>(null);
+let mainChart: echarts.ECharts | null = null;
+const mainChartEmpty = ref(false);
+
+const trendChartRef = ref<HTMLElement | null>(null);
+let trendChart: echarts.ECharts | null = null;
+
+// ─── 工具函数 ─────────────────────────────────────────────────
+function severityTagType(s: string) {
+	return ({ CRITICAL: 'danger', HIGH: 'warning', MEDIUM: '', LOW: 'success' } as any)[s] ?? 'info';
+}
+function statusTagType(s: string) {
+	return ({ NEW: 'info', ASSIGNED: '', IN_PROGRESS: 'warning', RESOLVED: 'success', CLOSED: 'success' } as any)[s] ?? 'info';
+}
+function fmtDate(v: string | null | undefined) {
+	return v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—';
+}
+
+// ─── 面板专属筛选项 ──────────────────────────────────────────
+const processNodeOptions = computed(() =>
+	(distributions.value?.byProcess ?? []).map(p => ({ label: p.nodeName, value: p.key }))
+);
+const occurrenceDeptOptions = computed(() =>
+	(distributions.value?.byOccurrenceDept ?? []).map(d => ({ label: d.deptName, value: String(d.key) }))
+);
+const responsibleDeptOptions = computed(() =>
+	(distributions.value?.byDept ?? []).map(d => ({ label: d.deptName, value: String(d.key) }))
+);
+
+// ─── 图表渲染 ─────────────────────────────────────────────────
+async function renderMainChart() {
+	await nextTick();
+	if (!mainChartRef.value) return;
+
+	if (!mainChart) {
+		mainChart = echarts.init(mainChartRef.value, undefined, { renderer: 'canvas' });
+	}
+
+	const panel = activePanel.value;
+	let categories: string[] = [];
+	let values: number[] = [];
+	let barColor = '#6366f1';
+	let title = '';
+
+	if (panel === 'object') {
+		const src = distributions.value?.byObject ?? [];
+		categories = src.map(x => x.key);
+		values     = src.map(x => x.count);
+		title = '受影响订单';
+		barColor = '#6366f1';
+	} else if (panel === 'process') {
+		const src = distributions.value?.byProcess ?? [];
+		categories = src.map(x => x.nodeName || x.key);
+		values     = src.map(x => x.count);
+		title = '流程节点异常分布';
+		barColor = '#3b82f6';
+	} else if (panel === 'occurrence') {
+		const src = distributions.value?.byOccurrenceDept ?? [];
+		categories = src.map(x => x.deptName || String(x.key));
+		values     = src.map(x => x.count);
+		title = '发生部门异常数';
+		barColor = '#f59e0b';
+	} else {
+		const src = deptBacklog.value;
+		categories = src.map(x => x.deptName || String(x.deptId));
+		values     = src.map(x => x.pending);
+		title = '处理部门待处理积压';
+		barColor = '#ef4444';
+	}
+
+	mainChartEmpty.value = values.length === 0;
+	if (mainChartEmpty.value) return;
+
+	// 超出 8 条时用渐变色标注高值
+	const itemColors = values.map((v, i) => {
+		const maxV = Math.max(...values, 1);
+		const ratio = v / maxV;
+		return ratio >= 0.8 ? '#ef4444' : ratio >= 0.5 ? '#f59e0b' : barColor;
+	});
+
+	mainChart.setOption({
+		title: { text: title, left: 4, top: 6, textStyle: { fontSize: 14, fontWeight: 700, color: '#1e293b' } },
+		tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+		grid: { left: 140, right: 48, top: 44, bottom: 20 },
+		xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } } },
+		yAxis: {
+			type: 'category',
+			data: categories,
+			axisLabel: { width: 128, overflow: 'truncate', color: '#475569', fontSize: 12 },
+			axisLine: { lineStyle: { color: '#e2e8f0' } },
+		},
+		series: [{
+			type: 'bar',
+			data: values.map((v, i) => ({ value: v, itemStyle: { color: itemColors[i], borderRadius: [0, 4, 4, 0] } })),
+			label: { show: true, position: 'right', color: '#64748b', fontSize: 12 },
+			barMaxWidth: 24,
+		}],
+	}, true);
+
+	mainChart.off('click');
+	mainChart.on('click', (params: any) => {
+		chartDrillValue.value = categories[params.dataIndex] ?? '';
+		detailPage.value = 1;
+		void loadDetail();
+	});
+}
+
+// ─── 右侧趋势图渲染 ──────────────────────────────────────────
+const STACK_COLORS = ['#6366f1','#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#14b8a6'];
+
+async function renderTrendChart() {
+	await nextTick();
+	if (!trendChartRef.value) return;
+	if (!trendChart) trendChart = echarts.init(trendChartRef.value);
+
+	const { dates, series } = dimTrendsData.value;
+	if (!series.length) {
+		trendChart.clear();
+		return;
+	}
+
+	trendChart.setOption({
+		tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+		legend: { bottom: 0, type: 'scroll', textStyle: { fontSize: 11 } },
+		grid: { left: 36, right: 8, top: 16, bottom: 48 },
+		xAxis: {
+			type: 'category',
+			data: dates.map(d => d.slice(5)),  // "yyyy-MM-dd" → "MM-dd"
+			axisLabel: { fontSize: 11, rotate: 30 },
+		},
+		yAxis: { type: 'value', minInterval: 1, splitLine: { lineStyle: { color: '#f1f5f9' } } },
+		series: series.map((s, i) => ({
+			name: s.name,
+			type: 'bar',
+			stack: 'total',
+			data: s.data,
+			itemStyle: { color: STACK_COLORS[i % STACK_COLORS.length] },
+			emphasis: { focus: 'series' },
+		})),
+	}, true);
+}
+
+// 面板 → dim 参数映射
+function panelToDim(panel: string) {
+	return { object: 'object', process: 'process', occurrence: 'occDept', responsible: 'respDept' }[panel] ?? 'object';
+}
+
+// 面板 → 中文标签(供模板使用)
+const panelDimLabel = computed(() => {
+	const map: Record<string, string> = { object: '订单', process: '流程节点', occurrence: '发生部门', responsible: '处理部门' };
+	return map[activePanel.value] ?? '';
+});
+
+const visibleKpiCards = computed(() => {
+	const visibleKeys = new Set(preferences.visibleKpiKeys);
+	return kpiCards.filter((card) => visibleKeys.has(card.key));
+});
+
+// ─── 数据加载 ─────────────────────────────────────────────────
+async function loadOverview() {
+	const data = await s8DashboardApi.overview();
+	Object.assign(overview, data);
+}
+
+async function loadChartData() {
+	const [dist, backlog] = await Promise.all([
+		s8DashboardApi.distributions(),
+		s8DashboardApi.deptBacklog(),
+	]);
+	distributions.value = dist;
+	deptBacklog.value   = backlog;
+}
+
+async function loadDimTrends() {
+	dimTrendsData.value = await s8DashboardApi.dimTrends({
+		dim: panelToDim(activePanel.value),
+		days: preferences.trendDays,
+	});
+}
+
+async function loadDetail() {
+	const params: Record<string, any> = {
+		page: detailPage.value,
+		pageSize: detailPageSize.value,
+	};
+	const filter = currentPanelFilter.value;
+	if (filter.severity) params.severity = filter.severity;
+	if (filter.dateStart) params.beginTime = filter.dateStart.toISOString();
+	if (filter.dateEnd) params.endTime = filter.dateEnd.toISOString();
+	if (filter.extra) {
+		if (activePanel.value === 'process') params.processNodeCode = filter.extra;
+		if (activePanel.value === 'occurrence') params.deptId = filter.extra;
+		if (activePanel.value === 'responsible') params.deptId = filter.extra;
+	}
+	if (chartDrillValue.value) {
+		if (activePanel.value === 'responsible') params.deptId = findDeptId(chartDrillValue.value, 'responsible');
+		if (activePanel.value === 'occurrence')  params.deptId = findDeptId(chartDrillValue.value, 'occurrence');
+		if (activePanel.value === 'process')     params.processNodeCode = findProcessCode(chartDrillValue.value);
+		if (activePanel.value === 'object')      params.relatedObjectCode = chartDrillValue.value;
+	}
+	try {
+		const res = await service.get('/api/aidop/s8/exceptions', { params });
+		const d = (res as any).data ?? res;
+		detailList.value  = Array.isArray(d.list) ? d.list : (Array.isArray(d) ? d : []);
+		detailTotal.value = d.total ?? detailList.value.length;
+	} catch {
+		detailList.value  = [];
+		detailTotal.value = 0;
+	}
+}
+
+function findDeptId(name: string, type: 'responsible' | 'occurrence') {
+	const src = type === 'responsible' ? distributions.value?.byDept : distributions.value?.byOccurrenceDept;
+	return String(src?.find(d => d.deptName === name)?.key ?? '');
+}
+function findProcessCode(name: string) {
+	return distributions.value?.byProcess?.find(p => p.nodeName === name)?.key ?? name;
+}
+
+async function loadAll() {
+	loading.value = true;
+	try {
+		await Promise.all([loadOverview(), loadChartData(), loadDimTrends()]);
+		await Promise.all([renderMainChart(), renderTrendChart()]);
+		await loadDetail();
+	} finally {
+		loading.value = false;
+		lastRefreshedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
+	}
+}
+
+// ─── 筛选 ─────────────────────────────────────────────────────
+function onQuery() {
+	chartDrillValue.value = '';
+	detailPage.value = 1;
+	void loadAll();
+}
+function onReset() {
+	Object.assign(currentPanelFilter.value, createDefaultPanelFilter());
+	chartDrillValue.value  = '';
+	detailPage.value = 1;
+	void loadAll();
+}
+
+// 面板切换时重绘图表 + 重载趋势数据
+watch(activePanel, async () => {
+	chartDrillValue.value  = '';
+	detailPage.value = 1;
+	await loadDimTrends();
+	await Promise.all([renderMainChart(), renderTrendChart()]);
+	void loadDetail();
+});
+
+watch(
+	() => preferences.sections.showTrendChart,
+	async () => {
+		await nextTick();
+		mainChart?.resize();
+		if (preferences.sections.showTrendChart) {
+			await renderTrendChart();
+		}
+		trendChart?.resize();
+	}
+);
+
+async function applyPreferences(nextConfig: DashboardPreferences) {
+	const previous = clonePreferences(preferences);
+	Object.assign(preferences, clonePreferences(nextConfig));
+	savePreferences(nextConfig);
+	if (activePanel.value !== nextConfig.defaultPanel) {
+		activePanel.value = nextConfig.defaultPanel;
+	} else {
+		if (previous.trendDays !== nextConfig.trendDays) {
+			await loadDimTrends();
+		}
+		await nextTick();
+		await Promise.all([renderMainChart(), preferences.sections.showTrendChart ? renderTrendChart() : Promise.resolve()]);
+	}
+	ElMessage.success('面板配置已生效');
+}
+
+function openConfigDrawer() {
+	configDrawerVisible.value = true;
+}
+
+function resetPreferences() {
+	void applyPreferences(clonePreferences(DEFAULT_PREFERENCES));
+}
+
+function onKpiClick(key: string) {
+	const q: Record<string, string> = {};
+	if (key === 'pending')    q.bucket = 'pending';
+	if (key === 'inProgress') q.status = 'IN_PROGRESS';
+	if (key === 'timeout')    q.timeoutFlag = 'true';
+	if (key === 'closed')     q.status = 'CLOSED';
+	if (key === 'todayNew')   q.todayNew = 'true';
+	if (Object.keys(q).length) router.push({ path: '/aidop/s8/exceptions', query: q });
+}
+
+function onDetailPageChange(page: number) {
+	detailPage.value = page;
+	void loadDetail();
+}
+
+onMounted(() => void loadAll());
+onBeforeUnmount(() => { mainChart?.dispose(); trendChart?.dispose(); });
+</script>
+
+<template>
+	<AidopDemoShell title="异常监控看板" subtitle="S8 异常协同 / KPI、趋势图、分布图">
+		<div v-loading="loading" class="s8-db">
+
+			<div class="s8-toolbar">
+				<div class="s8-toolbar__meta">
+					<span class="s8-toolbar__label">当前面板:{{ panels.find((item) => item.key === activePanel)?.label }}</span>
+					<span class="s8-toolbar__hint">支持按面板维护筛选条件</span>
+				</div>
+				<div class="s8-toolbar__actions">
+					<div v-if="preferences.sections.showRefreshBar" class="s8-refresh-bar">
+						<span class="s8-refresh-time">最近刷新:{{ lastRefreshedAt || '—' }}</span>
+						<el-button size="small" plain @click="onQuery">刷新</el-button>
+					</div>
+					<el-button size="small" @click="openConfigDrawer">面板配置</el-button>
+				</div>
+			</div>
+
+			<!-- ② 筛选栏 -->
+			<div v-if="preferences.sections.showFilterBar" class="s8-filter-bar">
+				<el-form inline class="s8-filter-form">
+					<el-form-item label="严重度">
+						<el-select v-model="currentPanelFilter.severity" clearable placeholder="全部" style="width:120px">
+							<el-option label="严重" value="CRITICAL" />
+							<el-option label="高"   value="HIGH" />
+							<el-option label="中"   value="MEDIUM" />
+							<el-option label="低"   value="LOW" />
+						</el-select>
+					</el-form-item>
+					<el-form-item label="开始日期">
+						<el-date-picker
+							v-model="currentPanelFilter.dateStart"
+							type="date"
+							placeholder="选择日期"
+							style="width:130px"
+						/>
+					</el-form-item>
+					<el-form-item label="结束日期">
+						<el-date-picker
+							v-model="currentPanelFilter.dateEnd"
+							type="date"
+							placeholder="选择日期"
+							style="width:130px"
+						/>
+					</el-form-item>
+					<el-form-item v-if="activePanel === 'process'" label="流程节点">
+						<el-select v-model="currentPanelFilter.extra" clearable placeholder="全部" style="width:130px">
+							<el-option v-for="o in processNodeOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+					<el-form-item v-if="activePanel === 'occurrence'" label="发生部门">
+						<el-select v-model="currentPanelFilter.extra" clearable placeholder="全部" style="width:130px">
+							<el-option v-for="o in occurrenceDeptOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+					<el-form-item v-if="activePanel === 'responsible'" label="处理部门">
+						<el-select v-model="currentPanelFilter.extra" clearable placeholder="全部" style="width:130px">
+							<el-option v-for="o in responsibleDeptOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+					<el-form-item>
+						<el-button type="primary" @click="onQuery">查询</el-button>
+						<el-button @click="onReset">重置</el-button>
+					</el-form-item>
+				</el-form>
+			</div>
+
+			<!-- ③ 面板 Tabs -->
+			<el-tabs v-model="activePanel" type="card" class="s8-tabs">
+				<el-tab-pane v-for="p in panels" :key="p.key" :label="p.label" :name="p.key" />
+			</el-tabs>
+
+			<!-- ④ KPI × 8 -->
+			<div v-if="preferences.sections.showKpiSection" class="s8-kpi-grid">
+				<div v-for="card in visibleKpiCards" :key="card.key">
+					<div class="s8-kpi" :style="{ '--kpi-color': card.color }" @click="onKpiClick(card.key)">
+						<div class="s8-kpi__label">{{ card.label }}</div>
+						<div class="s8-kpi__value">
+							{{ overview[card.key as keyof typeof overview] ?? 0 }}{{ card.suffix }}
+						</div>
+						<div class="s8-kpi__bar"></div>
+					</div>
+				</div>
+			</div>
+
+			<!-- ⑤ 主内容:左图 + 右 Top N -->
+			<el-row :gutter="12" class="s8-main-row">
+				<el-col :span="preferences.sections.showTrendChart ? 16 : 24">
+					<div class="s8-chart-wrap">
+						<!-- 有数据:图表 -->
+						<div v-show="!mainChartEmpty" ref="mainChartRef" class="s8-main-chart"></div>
+						<!-- 无数据:空态提示 -->
+						<div v-if="mainChartEmpty" class="s8-chart-empty">
+							<el-empty description="暂无数据">
+								<template #description>
+									<p style="color:#64748b;font-size:13px">当前面板暂无数据</p>
+									<p v-if="activePanel === 'object' || activePanel === 'process'"
+										style="color:#94a3b8;font-size:12px;margin-top:4px">
+										请先执行数据库初始化脚本,为异常记录分配流程节点和关联对象
+									</p>
+								</template>
+							</el-empty>
+						</div>
+						<!-- 下钻提示 -->
+						<div v-if="chartDrillValue" class="s8-drill-hint">
+							已下钻至:<strong>{{ chartDrillValue }}</strong>
+							<el-button link size="small" style="margin-left:8px"
+								@click="() => { chartDrillValue = ''; void loadDetail(); }">
+								清除筛选
+							</el-button>
+						</div>
+					</div>
+				</el-col>
+
+				<el-col v-if="preferences.sections.showTrendChart" :span="8">
+					<div class="s8-trend-wrap">
+						<div class="s8-trend-header">
+							每日异常趋势
+							<span class="s8-trend-sub">(近 {{ preferences.trendDays }} 天,按{{ panelDimLabel }}分组)</span>
+						</div>
+						<div ref="trendChartRef" class="s8-trend-chart"></div>
+					</div>
+				</el-col>
+			</el-row>
+
+			<!-- ⑥ 底部明细表 -->
+			<div v-if="preferences.sections.showDetailTable" class="s8-detail-wrap">
+				<div class="s8-detail-header">
+					异常明细
+					<span v-if="chartDrillValue" class="s8-detail-drill">(已筛选:{{ chartDrillValue }})</span>
+					<span class="s8-detail-total">共 {{ detailTotal }} 条</span>
+				</div>
+				<el-table :data="detailList" size="small" border stripe style="width:100%">
+					<el-table-column prop="exceptionCode" label="编号" width="140" />
+					<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
+					<el-table-column label="状态" width="96" align="center">
+						<template #default="{ row }">
+							<el-tag :type="statusTagType(row.status)" size="small" effect="light">
+								{{ row.statusLabel || row.status }}
+							</el-tag>
+						</template>
+					</el-table-column>
+					<el-table-column label="严重度" width="90" align="center">
+						<template #default="{ row }">
+							<el-tag :type="severityTagType(row.severity)" size="small" effect="dark">
+								{{ row.severityLabel || row.severity }}
+							</el-tag>
+						</template>
+					</el-table-column>
+					<el-table-column prop="responsibleDeptName" label="责任部门" width="120" show-overflow-tooltip />
+					<el-table-column label="SLA截止" width="148" align="center">
+						<template #default="{ row }">
+							<span :class="row.timeoutFlag ? 's8-timeout-text' : ''">
+								{{ fmtDate(row.slaDeadline) }}
+							</span>
+						</template>
+					</el-table-column>
+					<el-table-column label="超时" width="64" align="center">
+						<template #default="{ row }">
+							<el-tag v-if="row.timeoutFlag" type="danger" size="small" effect="dark">是</el-tag>
+						</template>
+					</el-table-column>
+					<el-table-column label="创建时间" width="148" align="center">
+						<template #default="{ row }">{{ fmtDate(row.createdAt) }}</template>
+					</el-table-column>
+					<el-table-column label="操作" width="68" fixed="right" align="center">
+						<template #default="{ row }">
+							<el-button link type="primary" size="small"
+								@click="router.push({ path: '/aidop/s8/exceptions', query: { id: String(row.id) } })">
+								查看
+							</el-button>
+						</template>
+					</el-table-column>
+				</el-table>
+				<el-pagination
+					class="s8-pagination"
+					background
+					layout="total, prev, pager, next"
+					:total="detailTotal"
+					:page-size="detailPageSize"
+					:current-page="detailPage"
+					@current-change="onDetailPageChange"
+				/>
+			</div>
+
+			<S8DashboardConfigDrawer
+				v-model="configDrawerVisible"
+				:config="preferences"
+				:kpi-options="kpiCards"
+				@save="applyPreferences"
+				@reset="resetPreferences"
+			/>
+
+		</div>
+	</AidopDemoShell>
+</template>
+
+<style scoped>
+/* ─── 容器 ─────────────────────────────────────────────── */
+.s8-db {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+	padding-bottom: 28px;
+}
+
+/* ─── 工具栏 / 刷新栏 ────────────────────────────────── */
+.s8-toolbar {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+}
+.s8-toolbar__meta {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	color: #475569;
+	font-size: 13px;
+}
+.s8-toolbar__label { font-weight: 600; color: #0f172a; }
+.s8-toolbar__hint { color: #94a3b8; }
+.s8-toolbar__actions {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+.s8-refresh-bar {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+.s8-refresh-time { font-size: 12px; color: #94a3b8; }
+
+/* ─── 筛选栏 ─────────────────────────────────────────── */
+.s8-filter-bar {
+	background: #f8fafc;
+	border: 1px solid #e2e8f0;
+	border-radius: 8px;
+	padding: 10px 16px 2px;
+}
+.s8-filter-form :deep(.el-form-item) { margin-bottom: 8px; }
+
+/* ─── 面板 Tabs ──────────────────────────────────────── */
+.s8-tabs { margin-bottom: 0; }
+.s8-tabs :deep(.el-tabs__item) { font-weight: 500; }
+.s8-tabs :deep(.el-tabs__item.is-active) { color: #6366f1; border-bottom-color: #6366f1; }
+
+/* ─── KPI 卡片 ───────────────────────────────────────── */
+.s8-kpi-grid {
+	display: grid;
+	grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+	gap: 10px;
+}
+.s8-kpi {
+	background: #fff;
+	border: 1px solid #e2e8f0;
+	border-radius: 10px;
+	padding: 14px 16px 10px;
+	cursor: pointer;
+	transition: box-shadow .2s, transform .15s;
+	position: relative;
+	overflow: hidden;
+}
+.s8-kpi:hover { box-shadow: 0 4px 16px rgba(0,0,0,.10); transform: translateY(-2px); }
+.s8-kpi__label { font-size: 12px; color: #64748b; margin-bottom: 6px; }
+.s8-kpi__value { font-size: 26px; font-weight: 700; color: var(--kpi-color); line-height: 1.1; }
+.s8-kpi__bar {
+	position: absolute;
+	bottom: 0; left: 0; right: 0;
+	height: 3px;
+	background: var(--kpi-color);
+	opacity: .25;
+}
+
+/* ─── 主内容区 ──────────────────────────────────────── */
+.s8-main-row { align-items: stretch; }
+.s8-chart-wrap {
+	background: #fff;
+	border: 1px solid #e2e8f0;
+	border-radius: 10px;
+	padding: 12px;
+	min-height: 360px;
+	display: flex;
+	flex-direction: column;
+}
+.s8-main-chart { width: 100%; height: 340px; flex: 1; }
+.s8-chart-empty { flex: 1; display: flex; align-items: center; justify-content: center; }
+.s8-drill-hint {
+	margin-top: 8px;
+	padding: 4px 10px;
+	background: #eff6ff;
+	border-radius: 6px;
+	font-size: 12px;
+	color: #3b82f6;
+}
+
+/* ─── 右侧趋势图 ──────────────────────────────────── */
+.s8-trend-wrap {
+	background: #fff;
+	border: 1px solid #e2e8f0;
+	border-radius: 10px;
+	padding: 12px 14px;
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+.s8-trend-header {
+	font-size: 14px;
+	font-weight: 700;
+	color: #1e293b;
+	margin-bottom: 8px;
+	flex-shrink: 0;
+}
+.s8-trend-sub { font-size: 12px; color: #94a3b8; font-weight: normal; margin-left: 4px; }
+.s8-trend-chart { flex: 1; min-height: 300px; }
+
+/* ─── 明细表 ──────────────────────────────────────── */
+.s8-detail-wrap {
+	background: #fff;
+	border: 1px solid #e2e8f0;
+	border-radius: 10px;
+	padding: 14px 16px;
+}
+.s8-detail-header {
+	font-size: 14px;
+	font-weight: 700;
+	color: #1e293b;
+	margin-bottom: 12px;
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+.s8-detail-drill { font-size: 12px; color: #6366f1; font-weight: normal; }
+.s8-detail-total { margin-left: auto; font-size: 12px; color: #94a3b8; font-weight: normal; }
+.s8-timeout-text { color: #ef4444; font-weight: 600; }
+.s8-pagination { margin-top: 12px; justify-content: flex-end; }
+</style>

+ 170 - 0
Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue

@@ -0,0 +1,170 @@
+<template>
+	<AidopDemoShell title="异常列表" subtitle="S8 异常协同 / 主检索入口">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="关键字">
+				<el-input v-model="query.keyword" placeholder="标题/编号" clearable style="width: 200px" />
+			</el-form-item>
+			<el-form-item label="状态">
+				<el-select v-model="query.status" clearable placeholder="全部" style="width: 140px" @change="onStatusChange">
+					<el-option v-for="s in filterOpts.statuses" :key="s.value" :label="s.label" :value="s.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="严重度">
+				<el-select v-model="query.severity" clearable placeholder="全部" style="width: 120px">
+					<el-option v-for="s in filterOpts.severities" :key="s.value" :label="s.label" :value="s.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="场景">
+				<el-select v-model="query.sceneCode" clearable placeholder="全部" style="width: 180px">
+					<el-option v-for="s in filterOpts.scenes" :key="s.value" :label="s.label" :value="s.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="部门">
+				<el-select v-model="query.deptId" clearable placeholder="全部" style="width: 180px" filterable>
+					<el-option v-for="s in filterOpts.departments" :key="s.value" :label="s.label" :value="s.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="loadList">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table :data="rows" v-loading="loading" border stripe @row-click="onRowClick">
+			<el-table-column prop="exceptionCode" label="编号" width="160" show-overflow-tooltip />
+			<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
+			<el-table-column prop="sceneName" label="场景" width="120" show-overflow-tooltip />
+			<el-table-column prop="statusLabel" label="状态" width="100" />
+			<el-table-column prop="severityLabel" label="严重度" width="90" />
+			<el-table-column prop="priorityLevel" label="优先级" width="90" />
+			<el-table-column prop="timeoutFlag" label="超时" width="70" align="center">
+				<template #default="{ row }">
+					<el-tag :type="row.timeoutFlag ? 'danger' : 'info'" size="small">{{ row.timeoutFlag ? '是' : '否' }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="createdAt" label="创建时间" width="170" />
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[20, 50, 100]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS8ExceptionList">
+import { onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import { s8ExceptionApi, type S8ExceptionRow } from '../api/s8ExceptionApi';
+
+const route = useRoute();
+const router = useRouter();
+const loading = ref(false);
+const rows = ref<S8ExceptionRow[]>([]);
+const total = ref(0);
+const filterOpts = reactive<{
+	statuses: { value: string; label: string }[];
+	severities: { value: string; label: string }[];
+	scenes: { value: string; label: string }[];
+	departments: { value: number; label: string }[];
+}>({
+	statuses: [],
+	severities: [],
+	scenes: [],
+	departments: [],
+});
+
+const query = reactive({
+	keyword: '',
+	status: '' as string,
+	statusBucket: '' as string,
+	severity: '' as string,
+	sceneCode: '' as string,
+	deptId: undefined as number | undefined,
+	timeoutFlag: undefined as boolean | undefined,
+	page: 1,
+	pageSize: 20,
+	tenantId: 1,
+	factoryId: 1,
+});
+
+async function loadFilters() {
+	const fo = (await s8ExceptionApi.filterOptions()) as {
+		statuses: { value: string; label: string }[];
+		severities: { value: string; label: string }[];
+		scenes?: { value: string; label: string }[];
+		departments?: { value: number; label: string }[];
+	};
+	filterOpts.statuses = fo.statuses ?? [];
+	filterOpts.severities = fo.severities ?? [];
+	filterOpts.scenes = fo.scenes ?? [];
+	filterOpts.departments = fo.departments ?? [];
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const res = await s8ExceptionApi.list({
+			...query,
+			status: query.status || undefined,
+			statusBucket: query.status ? undefined : query.statusBucket || undefined,
+			severity: query.severity || undefined,
+			sceneCode: query.sceneCode || undefined,
+			deptId: query.deptId || undefined,
+			keyword: query.keyword || undefined,
+			timeoutFlag: query.timeoutFlag,
+		});
+		rows.value = res.list;
+		total.value = res.total;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function resetQuery() {
+	query.keyword = '';
+	query.status = '';
+	query.statusBucket = '';
+	query.severity = '';
+	query.sceneCode = '';
+	query.deptId = undefined;
+	query.timeoutFlag = undefined;
+	query.page = 1;
+	void loadList();
+}
+
+function onRowClick(row: S8ExceptionRow) {
+	router.push(`/aidop/s8/exceptions/${row.id}`);
+}
+
+function onStatusChange() {
+	if (query.status) query.statusBucket = '';
+}
+
+onMounted(async () => {
+	if (route.query.status) query.status = String(route.query.status);
+	if (route.query.bucket) query.statusBucket = String(route.query.bucket);
+	if (route.query.timeout === '1' || route.query.timeoutFlag === 'true') query.timeoutFlag = true;
+	await loadFilters();
+	await loadList();
+});
+</script>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 291 - 0
Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue

@@ -0,0 +1,291 @@
+<script setup lang="ts" name="aidopS8TaskDetail">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import { useRoute, useRouter } from 'vue-router';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import { s8ExceptionApi, type S8DecisionRow, type S8EvidenceRow } from '../api/s8ExceptionApi';
+
+const route = useRoute();
+const router = useRouter();
+const loading = ref(false);
+const detail = ref<Record<string, any> | null>(null);
+const timeline = ref<{ id: number; actionLabel: string; operatorName?: string | null; actionRemark?: string | null; createdAt: string }[]>([]);
+const decisions = ref<S8DecisionRow[]>([]);
+const evidences = ref<S8EvidenceRow[]>([]);
+const employees = ref<{ id: number; name: string; empCode?: string }[]>([]);
+const dialogMode = ref('');
+const dialogVisible = ref(false);
+const submitting = ref(false);
+const actionForm = reactive({
+	assigneeId: undefined as number | undefined,
+	remark: '',
+});
+
+const imagePattern = /\.(png|jpe?g|gif|bmp|webp|svg)$/i;
+
+const imageEvidences = computed(() => evidences.value.filter((item) => imagePattern.test(item.fileUrl || item.fileName)));
+const fileEvidences = computed(() => evidences.value.filter((item) => !imagePattern.test(item.fileUrl || item.fileName)));
+const currentStatus = computed(() => String(detail.value?.status || ''));
+const canClaim = computed(() => currentStatus.value === 'NEW');
+const canTransfer = computed(() => !['', 'CLOSED'].includes(currentStatus.value));
+const canUpgrade = computed(() => ['ASSIGNED', 'IN_PROGRESS'].includes(currentStatus.value));
+const canReject = computed(() => ['NEW', 'ASSIGNED', 'IN_PROGRESS'].includes(currentStatus.value));
+const canClose = computed(() => currentStatus.value === 'RESOLVED');
+
+function goBack() {
+	router.push('/aidop/s8/exceptions');
+}
+
+async function loadDetail() {
+	const id = Number(route.params.id);
+	if (!id) return;
+	loading.value = true;
+	try {
+		const [detailRes, timelineRes, decisionRes, evidenceRes] = await Promise.all([
+			s8ExceptionApi.detail(id),
+			s8ExceptionApi.timeline(id),
+			s8ExceptionApi.decisions(id),
+			s8ExceptionApi.evidences(id),
+		]);
+		detail.value = detailRes as Record<string, any>;
+		timeline.value = timelineRes as typeof timeline.value;
+		decisions.value = decisionRes as S8DecisionRow[];
+		evidences.value = evidenceRes as S8EvidenceRow[];
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadEmployees() {
+	employees.value = (await s8ExceptionApi.employees({ factoryRefId: 1 })) as typeof employees.value;
+}
+
+function openAction(mode: string) {
+	dialogMode.value = mode;
+	dialogVisible.value = true;
+	actionForm.remark = '';
+	actionForm.assigneeId = undefined;
+}
+
+function actionTitle() {
+	return (
+		{
+			claim: '认领',
+			transfer: '转派',
+			upgrade: '升级',
+			reject: '驳回',
+			close: '关闭',
+			comment: '补充说明',
+		}[dialogMode.value] ?? '动作'
+	);
+}
+
+async function submitAction() {
+	const id = Number(route.params.id);
+	if (!id) return;
+	if ((dialogMode.value === 'claim' || dialogMode.value === 'transfer') && !actionForm.assigneeId) {
+		ElMessage.warning('请选择处理人');
+		return;
+	}
+
+	submitting.value = true;
+	try {
+		if (dialogMode.value === 'claim') {
+			await s8ExceptionApi.claim(id, { assigneeId: actionForm.assigneeId!, remark: actionForm.remark || undefined });
+		} else if (dialogMode.value === 'transfer') {
+			await s8ExceptionApi.transfer(id, { assigneeId: actionForm.assigneeId!, remark: actionForm.remark || undefined });
+		} else if (dialogMode.value === 'upgrade') {
+			await s8ExceptionApi.upgrade(id, { remark: actionForm.remark || undefined });
+		} else if (dialogMode.value === 'reject') {
+			await s8ExceptionApi.reject(id, { remark: actionForm.remark || undefined });
+		} else if (dialogMode.value === 'close') {
+			await s8ExceptionApi.close(id, { remark: actionForm.remark || undefined });
+		} else if (dialogMode.value === 'comment') {
+			await s8ExceptionApi.comment(id, { remark: actionForm.remark || undefined });
+		}
+		ElMessage.success(`${actionTitle()}成功`);
+		dialogVisible.value = false;
+		await loadDetail();
+	} finally {
+		submitting.value = false;
+	}
+}
+
+onMounted(async () => {
+	await Promise.all([loadDetail(), loadEmployees()]);
+});
+</script>
+
+<template>
+	<AidopDemoShell title="任务详情" subtitle="S8 异常协同 / 详情、动作与时间线">
+		<el-page-header @back="goBack" content="" />
+		<div v-if="detail" v-loading="loading" class="detail-page">
+			<el-row :gutter="16">
+				<el-col :span="16">
+					<el-descriptions :column="2" border>
+						<el-descriptions-item label="编号">{{ detail.exceptionCode }}</el-descriptions-item>
+						<el-descriptions-item label="状态">{{ detail.statusLabel }}</el-descriptions-item>
+						<el-descriptions-item label="严重度">{{ detail.severityLabel }}</el-descriptions-item>
+						<el-descriptions-item label="场景">{{ detail.sceneName || detail.sceneCode }}</el-descriptions-item>
+						<el-descriptions-item label="发生部门">{{ detail.occurrenceDeptName || detail.occurrenceDeptId || '—' }}</el-descriptions-item>
+						<el-descriptions-item label="责任部门">{{ detail.responsibleDeptName || detail.responsibleDeptId || '—' }}</el-descriptions-item>
+						<el-descriptions-item label="处理人">{{ detail.assigneeName || detail.assigneeId || '—' }}</el-descriptions-item>
+						<el-descriptions-item label="提报人">{{ detail.reporterName || detail.reporterId || '—' }}</el-descriptions-item>
+						<el-descriptions-item label="优先级">{{ detail.priorityLevel }} / {{ detail.priorityScore }}</el-descriptions-item>
+						<el-descriptions-item label="SLA 截止">{{ detail.slaDeadline || '—' }}</el-descriptions-item>
+						<el-descriptions-item label="标题" :span="2">{{ detail.title }}</el-descriptions-item>
+						<el-descriptions-item label="描述" :span="2">{{ detail.description || '—' }}</el-descriptions-item>
+					</el-descriptions>
+				</el-col>
+
+				<el-col :span="8">
+					<el-card shadow="never">
+						<template #header>操作面板</template>
+						<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>
+						<div class="muted mt12">按钮状态按当前异常状态控制,执行后会刷新详情与时间线。</div>
+					</el-card>
+				</el-col>
+			</el-row>
+
+			<el-row :gutter="16" class="mt16">
+				<el-col :span="12">
+					<el-card shadow="never">
+						<template #header>时间线</template>
+						<el-timeline v-if="timeline.length">
+							<el-timeline-item v-for="item in timeline" :key="item.id" :timestamp="item.createdAt">
+								<div class="timeline-title">{{ item.actionLabel }}</div>
+								<div v-if="item.operatorName" class="muted">操作人:{{ item.operatorName }}</div>
+								<div v-if="item.actionRemark" class="muted">{{ item.actionRemark }}</div>
+							</el-timeline-item>
+						</el-timeline>
+						<el-empty v-else description="暂无时间线" />
+					</el-card>
+				</el-col>
+
+				<el-col :span="12">
+					<el-card shadow="never">
+						<template #header>决策记录</template>
+						<el-empty v-if="!decisions.length" description="暂无决策记录" />
+						<div v-for="item in decisions" :key="item.id" class="decision-item">
+							<div class="decision-title">{{ item.decisionType }} / {{ item.decisionMakerName }}</div>
+							<div class="muted">{{ item.decisionTime }}</div>
+							<div v-if="item.decisionBasis" class="decision-line">依据:{{ item.decisionBasis }}</div>
+							<div v-if="item.decisionResult" class="decision-line">结论:{{ item.decisionResult }}</div>
+							<div v-if="item.decisionRemark" class="decision-line">备注:{{ item.decisionRemark }}</div>
+						</div>
+					</el-card>
+				</el-col>
+			</el-row>
+
+			<el-card shadow="never" class="mt16">
+				<template #header>证据附件</template>
+				<el-empty v-if="!evidences.length" description="暂无附件" />
+				<div v-if="imageEvidences.length" class="evidence-grid">
+					<el-image
+						v-for="item in imageEvidences"
+						:key="item.id"
+						:src="item.fileUrl"
+						:preview-src-list="imageEvidences.map((row) => row.fileUrl)"
+						fit="cover"
+						class="evidence-image"
+					/>
+				</div>
+				<div v-if="fileEvidences.length" class="file-list">
+					<div v-for="item in fileEvidences" :key="item.id" class="file-item">
+						<a :href="item.fileUrl" target="_blank" rel="noreferrer">{{ item.fileName }}</a>
+						<span class="muted">{{ item.uploadedAt }}</span>
+					</div>
+				</div>
+			</el-card>
+		</div>
+
+		<el-dialog v-model="dialogVisible" :title="actionTitle()" width="520px">
+			<el-form label-width="100px">
+				<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 label="备注">
+					<el-input v-model="actionForm.remark" type="textarea" :rows="4" />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="dialogVisible = false">取消</el-button>
+				<el-button type="primary" :loading="submitting" @click="submitAction">确定</el-button>
+			</template>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
+<style scoped>
+.detail-page {
+	margin-top: 12px;
+}
+
+.mt12 {
+	margin-top: 12px;
+}
+
+.mt16 {
+	margin-top: 16px;
+}
+
+.muted {
+	color: var(--el-text-color-secondary);
+	font-size: 13px;
+}
+
+.action-grid {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 12px;
+}
+
+.timeline-title,
+.decision-title {
+	font-weight: 600;
+	margin-bottom: 4px;
+}
+
+.decision-item + .decision-item {
+	margin-top: 12px;
+	padding-top: 12px;
+	border-top: 1px solid var(--el-border-color-light);
+}
+
+.decision-line {
+	margin-top: 4px;
+}
+
+.evidence-grid {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+	gap: 12px;
+}
+
+.evidence-image {
+	width: 100%;
+	height: 120px;
+	border-radius: 6px;
+}
+
+.file-list {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.file-item {
+	display: flex;
+	justify-content: space-between;
+	gap: 12px;
+}
+</style>

+ 147 - 0
Web/src/views/aidop/s8/report/S8ManualReportPage.vue

@@ -0,0 +1,147 @@
+<script setup lang="ts" name="aidopS8ManualReport">
+import { onMounted, reactive, ref } from 'vue';
+import type { UploadFile, UploadInstance } from 'element-plus';
+import { ElMessage } from 'element-plus';
+import { useRouter } from 'vue-router';
+import { getAPI } from '/@/utils/axios-utils';
+import { SysFileApi } from '/@/api-services';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import { s8ReportApi } from '../api/s8ReportApi';
+
+const router = useRouter();
+const saving = ref(false);
+const uploadRef = ref<UploadInstance>();
+const attachmentFiles = ref<File[]>([]);
+const formOptions = reactive<{
+	scenes: { value: string; label: string }[];
+	severities: { value: string; label: string }[];
+	departments: { value: number; label: string }[];
+	lines: { value: number; label: string }[];
+}>({
+	scenes: [],
+	severities: [],
+	departments: [],
+	lines: [],
+});
+
+const form = reactive({
+	title: '',
+	sceneCode: '',
+	severity: 'MEDIUM',
+	description: '',
+	occurrenceDeptId: undefined as number | undefined,
+	responsibleDeptId: undefined as number | undefined,
+	tenantId: 1,
+	factoryId: 1,
+});
+
+async function loadOptions() {
+	const result = (await s8ReportApi.formOptions({ tenantId: 1, factoryId: 1 })) as Record<string, any[]>;
+	formOptions.scenes = (result.scenes ?? []).map((item) => ({ value: item.value, label: item.label }));
+	formOptions.severities = (result.severities ?? []).map((item) => ({ value: item.value, label: item.label }));
+	formOptions.departments = (result.departments ?? []).map((item) => ({ value: item.value, label: item.label }));
+	formOptions.lines = (result.lines ?? []).map((item) => ({ value: item.value, label: item.label }));
+	if (!form.sceneCode && formOptions.scenes[0]) form.sceneCode = formOptions.scenes[0].value;
+	if (!form.occurrenceDeptId && formOptions.departments[0]) form.occurrenceDeptId = formOptions.departments[0].value;
+	if (!form.responsibleDeptId && formOptions.departments[0]) form.responsibleDeptId = formOptions.departments[0].value;
+}
+
+function onFileChange(uploadFile: UploadFile) {
+	if (uploadFile.raw) {
+		attachmentFiles.value = [...attachmentFiles.value, uploadFile.raw];
+	}
+}
+
+function onFileRemove(uploadFile: UploadFile) {
+	attachmentFiles.value = attachmentFiles.value.filter((file) => file.name !== uploadFile.name);
+}
+
+async function uploadAttachments(exceptionId: number) {
+	for (const file of attachmentFiles.value) {
+		const { data } = await getAPI(SysFileApi).apiSysFileUploadFilePostForm(file);
+		if (data.type !== 'success' || !data.result) continue;
+		await s8ReportApi.attachments(exceptionId, {
+			fileName: data.result.fileName ?? file.name,
+			fileUrl: data.result.url,
+			evidenceType: 'file',
+		});
+	}
+}
+
+async function submit() {
+	if (!form.title.trim() || !form.sceneCode.trim()) {
+		ElMessage.warning('请填写标题并选择场景');
+		return;
+	}
+	if (!form.occurrenceDeptId || !form.responsibleDeptId) {
+		ElMessage.warning('请选择发生部门和责任部门');
+		return;
+	}
+
+	saving.value = true;
+	try {
+		const res = (await s8ReportApi.create(form)) as { exceptionId: number };
+		if (attachmentFiles.value.length > 0) {
+			await uploadAttachments(res.exceptionId);
+		}
+		uploadRef.value?.clearFiles();
+		attachmentFiles.value = [];
+		ElMessage.success('提报成功');
+		router.push(`/aidop/s8/exceptions/${res.exceptionId}`);
+	} finally {
+		saving.value = false;
+	}
+}
+
+onMounted(() => {
+	void loadOptions();
+});
+</script>
+
+<template>
+	<AidopDemoShell title="主动提报" subtitle="S8 异常协同 / 人工上报">
+		<el-form :model="form" label-width="120px" style="max-width: 760px">
+			<el-form-item label="标题" required>
+				<el-input v-model="form.title" placeholder="简要描述异常" />
+			</el-form-item>
+			<el-form-item label="场景" required>
+				<el-select v-model="form.sceneCode" style="width: 100%" clearable>
+					<el-option v-for="item in formOptions.scenes" :key="item.value" :label="item.label" :value="item.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="严重度">
+				<el-select v-model="form.severity" style="width: 240px">
+					<el-option v-for="item in formOptions.severities" :key="item.value" :label="item.label" :value="item.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="发生部门" required>
+				<el-select v-model="form.occurrenceDeptId" style="width: 100%" clearable filterable>
+					<el-option v-for="item in formOptions.departments" :key="item.value" :label="item.label" :value="item.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="责任部门" required>
+				<el-select v-model="form.responsibleDeptId" style="width: 100%" clearable filterable>
+					<el-option v-for="item in formOptions.departments" :key="item.value" :label="item.label" :value="item.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="详细说明">
+				<el-input v-model="form.description" type="textarea" :rows="4" />
+			</el-form-item>
+			<el-form-item label="附件">
+				<el-upload
+					ref="uploadRef"
+					action
+					:auto-upload="false"
+					:on-change="onFileChange"
+					:on-remove="onFileRemove"
+					multiple
+				>
+					<el-button>选择附件</el-button>
+				</el-upload>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" :loading="saving" @click="submit">提交</el-button>
+			</el-form-item>
+		</el-form>
+	</AidopDemoShell>
+</template>

+ 1 - 0
Web/tsconfig.json

@@ -66,6 +66,7 @@
 		// "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
 
 		/* Advanced Options */
+		"ignoreDeprecations": "5.0" /* Suppress deprecated TypeScript option diagnostics from legacy project config. */,
 		"skipLibCheck": true /* Skip type checking of declaration files. */,
 		"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
 	},

+ 1 - 1
ai-dop-platform/docs/AIDOP_MENU_SEED.md

@@ -60,7 +60,7 @@ mods = [
     ("S5", "S5 物料仓储", [("来料检验", "IQC来料质量检验", "中", "3", ""), ("仓储管理", "仓库入库、出库、调拨", "中", "4", ""), ("库存数据", "库存查询、盘点、调整", "中", "3", ""), ("物料仓储看板", "仓储数据可视化分析", "中", "4", "数据看板类")]),
     ("S6", "S6 生产执行", [("生产记录管理", "生产过程数据记录", "中", "3", ""), ("过程质量管理", "IPQC过程质量检验", "中", "4", ""), ("设备工装管理", "设备台账、保养、维修", "中", "4", ""), ("生产执行看板", "生产执行数据可视化", "高", "5", "数据看板类")]),
     ("S7", "S7 成品仓储", [("成品质量管理", "OQC成品质量检验", "中", "3", ""), ("生产入库管理", "成品入库流程", "低", "2", ""), ("成品出库管理", "成品出库发货流程", "低", "2", ""), ("成品库存管理", "成品库存查询与管理", "中", "3", "")]),
-    ("S8", "S8 异常监控", [("异常管理", "生产异常上报、处理、跟踪", "中", "4", "")]),
+    ("S8", "S8 异常监控", [("异常监控看板", "生产异常监控、处理、跟踪", "中", "4", "")]),
     ("S9", "S9 运营指标", [("ERP同步", "与外部ERP系统数据同步", "高", "7", "接口集成"), ("日志查询", "系统操作日志查询", "低", "2", ""), ("ERP事务", "ERP相关事务处理", "中", "3", "")]),
     ("M11", "系统管理", [("组织架构", "部门、岗位、人员管理", "中", "3", "与框架系统管理对应,后续可映射具体页"), ("菜单管理", "系统菜单权限配置", "中", "3", "")]),
     ("M12", "流程平台", [("流程管理", "工作流流程定义与配置", "高", "7", "核心引擎"), ("表单管理", "流程表单设计与配置", "高", "5", ""), ("应用设计", "业务应用快速设计", "高", "5", ""), ("数据资源配置", "数据资源连接配置", "中", "4", ""), ("格式化JSON", "JSON数据格式化工具", "低", "1", "工具类"), ("模板管理", "流程模板管理", "中", "3", ""), ("系统按钮", "系统按钮权限配置", "低", "2", ""), ("流程按钮", "流程操作按钮配置", "低", "2", ""), ("应用程序", "外部应用集成管理", "中", "4", ""), ("接口系统", "API接口配置管理", "高", "5", "")]),

+ 1 - 1
ai-dop-platform/tools/gen_aidop_menu.py

@@ -111,7 +111,7 @@ mods = [
     ("S5", "S5 物料仓储", [("来料检验", "IQC来料质量检验", "中", "3", ""), ("仓储管理", "仓库入库、出库、调拨", "中", "4", ""), ("库存数据", "库存查询、盘点、调整", "中", "3", ""), ("物料仓储看板", "仓储数据可视化分析", "中", "4", "数据看板类")]),
     ("S6", "S6 生产执行", [("生产记录管理", "生产过程数据记录", "中", "3", ""), ("过程质量管理", "IPQC过程质量检验", "中", "4", ""), ("设备工装管理", "设备台账、保养、维修", "中", "4", ""), ("生产执行看板", "生产执行数据可视化", "高", "5", "数据看板类")]),
     ("S7", "S7 成品仓储", [("成品质量管理", "OQC成品质量检验", "中", "3", ""), ("生产入库管理", "成品入库流程", "低", "2", ""), ("成品出库管理", "成品出库发货流程", "低", "2", ""), ("成品库存管理", "成品库存查询与管理", "中", "3", "")]),
-    ("S8", "S8 异常监控", [("异常管理", "生产异常上报、处理、跟踪", "中", "4", "")]),
+    ("S8", "S8 异常监控", [("异常监控看板", "生产异常监控、处理、跟踪", "中", "4", "")]),
     ("S9", "S9 运营指标", [("ERP同步", "与外部ERP系统数据同步", "高", "7", "接口集成"), ("日志查询", "系统操作日志查询", "低", "2", ""), ("ERP事务", "ERP相关事务处理", "中", "3", "")]),
     ("M11", "系统管理", [("组织架构", "部门、岗位、人员管理", "中", "3", "与框架系统管理对应,后续可映射具体页"), ("菜单管理", "系统菜单权限配置", "中", "3", "")]),
     ("M12", "流程平台", [("流程管理", "工作流流程定义与配置", "高", "7", "核心引擎"), ("表单管理", "流程表单设计与配置", "高", "5", ""), ("应用设计", "业务应用快速设计", "高", "5", ""), ("数据资源配置", "数据资源连接配置", "中", "4", ""), ("格式化JSON", "JSON数据格式化工具", "低", "1", "工具类"), ("模板管理", "流程模板管理", "中", "3", ""), ("系统按钮", "系统按钮权限配置", "低", "2", ""), ("流程按钮", "流程操作按钮配置", "低", "2", ""), ("应用程序", "外部应用集成管理", "中", "4", ""), ("接口系统", "API接口配置管理", "高", "5", "")]),

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigAlertRulesController.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/alert-rules")]
+[NonUnify]
+public class AdoS8ConfigAlertRulesController : ControllerBase
+{
+    private readonly S8AlertRuleService _svc;
+
+    public AdoS8ConfigAlertRulesController(S8AlertRuleService 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] AdoS8AlertRule 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] AdoS8AlertRule 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();
+    }
+}

+ 46 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDataSourcesController.cs

@@ -0,0 +1,46 @@
+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/data-sources")]
+[NonUnify]
+public class AdoS8ConfigDataSourcesController : ControllerBase
+{
+    private readonly S8DataSourceService _svc;
+
+    public AdoS8ConfigDataSourcesController(S8DataSourceService 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] AdoS8DataSource 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] AdoS8DataSource 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();
+    }
+
+    [HttpPost("{id:long}/test")]
+    public async Task<IActionResult> TestAsync(long id)
+    {
+        try { return Ok(await _svc.TestAsync(id)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+}

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLayersController.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/notification-layers")]
+[NonUnify]
+public class AdoS8ConfigNotificationLayersController : ControllerBase
+{
+    private readonly S8NotificationLayerService _svc;
+
+    public AdoS8ConfigNotificationLayersController(S8NotificationLayerService 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] AdoS8NotificationLayer 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] AdoS8NotificationLayer 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();
+    }
+}

+ 48 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigRolesController.cs

@@ -0,0 +1,48 @@
+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/roles")]
+[NonUnify]
+public class AdoS8ConfigRolesController : ControllerBase
+{
+    private readonly S8RoleConfigService _svc;
+
+    public AdoS8ConfigRolesController(S8RoleConfigService 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] AdoS8RolePermissionConfig 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] AdoS8RolePermissionConfig 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();
+    }
+
+    /// <summary>
+    /// 从系统角色表一键导入,返回本次新增条数。
+    /// </summary>
+    [HttpPost("import-from-sys")]
+    public async Task<IActionResult> ImportFromSysAsync(
+        [FromQuery] long tenantId = 1,
+        [FromQuery] long factoryId = 1)
+        => Ok(new { imported = await _svc.ImportFromSysRolesAsync(tenantId, factoryId) });
+}

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigScenesController.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/scenes")]
+[NonUnify]
+public class AdoS8ConfigScenesController : ControllerBase
+{
+    private readonly S8SceneConfigService _svc;
+
+    public AdoS8ConfigScenesController(S8SceneConfigService 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] AdoS8SceneConfig 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] AdoS8SceneConfig 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();
+    }
+}

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

@@ -0,0 +1,46 @@
+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/watch-rules")]
+[NonUnify]
+public class AdoS8ConfigWatchRulesController : ControllerBase
+{
+    private readonly S8WatchRuleService _svc;
+
+    public AdoS8ConfigWatchRulesController(S8WatchRuleService 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] AdoS8WatchRule 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] AdoS8WatchRule 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();
+    }
+
+    [HttpPost("{id:long}/test")]
+    public async Task<IActionResult> TestAsync(long id)
+    {
+        try { return Ok(await _svc.TestAsync(id)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+}

+ 43 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DashboardController.cs

@@ -0,0 +1,43 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/dashboard")]
+[NonUnify]
+public class AdoS8DashboardController : ControllerBase
+{
+    private readonly S8DashboardService _svc;
+
+    public AdoS8DashboardController(S8DashboardService svc) => _svc = svc;
+
+    [HttpGet("overview")]
+    public async Task<IActionResult> OverviewAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
+        Ok(await _svc.GetOverviewAsync(tenantId, factoryId));
+
+    [HttpGet("trends")]
+    public async Task<IActionResult> TrendsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromQuery] int days = 14) =>
+        Ok(await _svc.GetTrendsAsync(tenantId, factoryId, days));
+
+    [HttpGet("distributions")]
+    public async Task<IActionResult> DistributionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
+        Ok(await _svc.GetDistributionsAsync(tenantId, factoryId));
+
+    [HttpGet("quick-exceptions")]
+    public async Task<IActionResult> QuickExceptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromQuery] string mode = "latest") =>
+        Ok(await _svc.GetQuickExceptionsAsync(tenantId, factoryId, mode));
+
+    [HttpGet("dept-backlog")]
+    public async Task<IActionResult> DeptBacklogAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
+        Ok(await _svc.GetDeptBacklogAsync(tenantId, factoryId));
+
+    [HttpGet("dim-trends")]
+    public async Task<IActionResult> DimTrendsAsync(
+        [FromQuery] long tenantId = 1,
+        [FromQuery] long factoryId = 1,
+        [FromQuery] string dim = "object",
+        [FromQuery] int days = 14) =>
+        Ok(await _svc.GetDimTrendsAsync(tenantId, factoryId, dim, days));
+}

+ 20 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DictionariesController.cs

@@ -0,0 +1,20 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/dictionaries")]
+[NonUnify]
+public class AdoS8DictionariesController : ControllerBase
+{
+    private readonly S8DictionaryService _svc;
+
+    public AdoS8DictionariesController(S8DictionaryService svc) => _svc = svc;
+
+    [HttpGet("{dictType}")]
+    public IActionResult Get(string dictType)
+    {
+        var result = _svc.GetDictionary(dictType);
+        return result == null ? NotFound(new { message = "未知字典类型" }) : Ok(result);
+    }
+}

+ 131 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs

@@ -0,0 +1,131 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/exceptions")]
+[NonUnify]
+public class AdoS8ExceptionsController : ControllerBase
+{
+    private readonly S8ExceptionService _exceptionSvc;
+    private readonly S8TaskFlowService _taskFlowSvc;
+    private readonly S8DecisionService _decisionSvc;
+
+    public AdoS8ExceptionsController(
+        S8ExceptionService exceptionSvc,
+        S8TaskFlowService taskFlowSvc,
+        S8DecisionService decisionSvc)
+    {
+        _exceptionSvc = exceptionSvc;
+        _taskFlowSvc = taskFlowSvc;
+        _decisionSvc = decisionSvc;
+    }
+
+    [HttpGet]
+    public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS8ExceptionQueryDto q)
+    {
+        var (total, list) = await _exceptionSvc.GetPagedAsync(q);
+        return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
+    }
+
+    [HttpGet("filter-options")]
+    public async Task<IActionResult> GetFilterOptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
+        Ok(await _exceptionSvc.GetFilterOptionsAsync(tenantId, factoryId));
+
+    [HttpGet("{id:long}")]
+    public async Task<IActionResult> GetDetailAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
+    {
+        var detail = await _exceptionSvc.GetDetailAsync(id, tenantId, factoryId);
+        return detail == null ? NotFound() : Ok(detail);
+    }
+
+    [HttpGet("{id:long}/timeline")]
+    public async Task<IActionResult> GetTimelineAsync(long id) =>
+        Ok(await _decisionSvc.GetTimelineAsync(id));
+
+    [HttpGet("{id:long}/decisions")]
+    public async Task<IActionResult> GetDecisionsAsync(long id) =>
+        Ok(await _decisionSvc.GetDecisionsAsync(id));
+
+    [HttpGet("{id:long}/evidences")]
+    public async Task<IActionResult> GetEvidencesAsync(long id) =>
+        Ok(await _decisionSvc.GetEvidencesAsync(id));
+
+    [HttpPost("{id:long}/claim")]
+    public async Task<IActionResult> ClaimAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromBody] AdoS8ClaimBody? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.ClaimAsync(id, tenantId, factoryId, body?.AssigneeId ?? 0, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPost("{id:long}/transfer")]
+    public async Task<IActionResult> TransferAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromBody] AdoS8TransferDto? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.TransferAsync(id, tenantId, factoryId, body?.AssigneeId ?? 0, body?.Remark);
+            return Ok(new { id = e.Id, assigneeId = e.AssigneeId });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPost("{id:long}/upgrade")]
+    public async Task<IActionResult> UpgradeAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromBody] AdoS8CommentDto? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.UpgradeAsync(id, tenantId, factoryId, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPost("{id:long}/reject")]
+    public async Task<IActionResult> RejectAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromBody] AdoS8CommentDto? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.RejectAsync(id, tenantId, factoryId, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPost("{id:long}/close")]
+    public async Task<IActionResult> CloseAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
+        [FromBody] AdoS8CommentDto? body = null)
+    {
+        try
+        {
+            var e = await _taskFlowSvc.CloseAsync(id, tenantId, factoryId, body?.Remark);
+            return Ok(new { id = e.Id, status = e.Status });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpPost("{id:long}/comment")]
+    public async Task<IActionResult> CommentAsync(long id, [FromBody] AdoS8CommentDto? body = null)
+    {
+        try
+        {
+            await _taskFlowSvc.CommentAsync(id, body?.Remark);
+            return Ok(new { id });
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+}
+
+public class AdoS8ClaimBody
+{
+    public long AssigneeId { get; set; }
+    public string? Remark { get; set; }
+}

+ 28 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MasterDataController.cs

@@ -0,0 +1,28 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// S8 主数据下拉:优先复用 S0 已落库主数据语义(部门等),无则返回空列表,不把「下拉查询」误建成独立业务表。
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/master-data")]
+[NonUnify]
+public class AdoS8MasterDataController : ControllerBase
+{
+    private readonly S8MasterDataAdapter _svc;
+
+    public AdoS8MasterDataController(S8MasterDataAdapter svc) => _svc = svc;
+
+    [HttpGet("departments")]
+    public async Task<IActionResult> DepartmentsAsync([FromQuery] long? factoryRefId = 1) =>
+        Ok(await _svc.GetDepartmentsAsync(factoryRefId));
+
+    [HttpGet("employees")]
+    public async Task<IActionResult> EmployeesAsync([FromQuery] long? factoryRefId = 1) =>
+        Ok(await _svc.GetEmployeesAsync(factoryRefId));
+
+    [HttpGet("lines")]
+    public async Task<IActionResult> LinesAsync([FromQuery] long? factoryRefId = 1) =>
+        Ok(await _svc.GetLinesAsync(factoryRefId));
+}

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

@@ -0,0 +1,39 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/reports")]
+[NonUnify]
+public class AdoS8ReportsController : ControllerBase
+{
+    private readonly S8ManualReportService _svc;
+
+    public AdoS8ReportsController(S8ManualReportService svc) => _svc = svc;
+
+    [HttpGet("form-options")]
+    public async Task<IActionResult> FormOptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
+        Ok(await _svc.GetFormOptionsAsync(tenantId, factoryId));
+
+    [HttpPost]
+    public async Task<IActionResult> CreateAsync([FromBody] AdoS8ManualReportCreateDto dto)
+    {
+        try { return Ok(await _svc.CreateAsync(dto)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    [HttpGet("{id:long}")]
+    public async Task<IActionResult> GetAsync(long id)
+    {
+        var e = await _svc.GetAsync(id);
+        return e == null ? NotFound() : Ok(e);
+    }
+
+    [HttpPost("{id:long}/attachments")]
+    public async Task<IActionResult> AttachmentsAsync(long id, [FromBody] AdoS8AttachmentCreateDto dto)
+    {
+        try { return Ok(await _svc.AddAttachmentAsync(id, dto)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+}

+ 132 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -0,0 +1,132 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.S8;
+
+public class AdoS8ExceptionQueryDto
+{
+    public long TenantId { get; set; } = 1;
+    public long FactoryId { get; set; } = 1;
+    public string? Keyword { get; set; }
+    public string? Status { get; set; }
+    public string? StatusBucket { get; set; }
+    public string? Severity { get; set; }
+    public string? SceneCode { get; set; }
+    public long? DeptId { get; set; }
+    public bool? TimeoutFlag { get; set; }
+    public string? ProcessNodeCode { get; set; }
+    public string? RelatedObjectCode { get; set; }
+    public DateTime? BeginTime { get; set; }
+    public DateTime? EndTime { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+public class AdoS8ExceptionListItemDto
+{
+    public long Id { get; set; }
+    public string ExceptionCode { get; set; } = string.Empty;
+    public string Title { get; set; } = string.Empty;
+    public string Status { get; set; } = string.Empty;
+    public string? StatusLabel { get; set; }
+    public string Severity { get; set; } = string.Empty;
+    public string? SeverityLabel { get; set; }
+    public decimal PriorityScore { get; set; }
+    public string PriorityLevel { get; set; } = string.Empty;
+    public string SceneCode { get; set; } = string.Empty;
+    public string? SceneName { get; set; }
+    public long ResponsibleDeptId { get; set; }
+    public string? ResponsibleDeptName { get; set; }
+    public long? AssigneeId { get; set; }
+    public string? AssigneeName { get; set; }
+    public DateTime? SlaDeadline { get; set; }
+    public bool TimeoutFlag { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public DateTime? ClosedAt { get; set; }
+}
+
+public class AdoS8ExceptionDetailDto : AdoS8ExceptionListItemDto
+{
+    public string? Description { get; set; }
+    public string SourceType { get; set; } = string.Empty;
+    public long OccurrenceDeptId { get; set; }
+    public string? OccurrenceDeptName { get; set; }
+    public long? ResponsibleGroupId { get; set; }
+    public long? ReporterId { get; set; }
+    public string? ReporterName { get; set; }
+    public DateTime? AssignedAt { get; set; }
+    public DateTime? UpdatedAt { get; set; }
+}
+
+public class AdoS8ManualReportCreateDto
+{
+    public long TenantId { get; set; } = 1;
+    public long FactoryId { get; set; } = 1;
+    public string Title { get; set; } = string.Empty;
+    public string? Description { get; set; }
+    public string SceneCode { get; set; } = string.Empty;
+    public string Severity { get; set; } = "MEDIUM";
+    public long OccurrenceDeptId { get; set; }
+    public long ResponsibleDeptId { get; set; }
+    public long? ReporterId { get; set; }
+}
+
+public class AdoS8ManualReportResultDto
+{
+    public long ExceptionId { get; set; }
+    public string ExceptionCode { get; set; } = string.Empty;
+    public long TaskId { get; set; }
+}
+
+public class AdoS8TimelineItemDto
+{
+    public long Id { get; set; }
+    public string ActionCode { get; set; } = string.Empty;
+    public string ActionLabel { get; set; } = string.Empty;
+    public string? FromStatus { get; set; }
+    public string? ToStatus { get; set; }
+    public long? OperatorId { get; set; }
+    public string? OperatorName { get; set; }
+    public string? ActionRemark { get; set; }
+    public DateTime CreatedAt { get; set; }
+}
+
+public class AdoS8DecisionItemDto
+{
+    public long Id { get; set; }
+    public string DecisionType { get; set; } = string.Empty;
+    public long DecisionMakerId { get; set; }
+    public string DecisionMakerName { get; set; } = string.Empty;
+    public string? DecisionBasis { get; set; }
+    public string? DecisionResult { get; set; }
+    public string? DecisionRemark { get; set; }
+    public DateTime DecisionTime { get; set; }
+}
+
+public class AdoS8EvidenceItemDto
+{
+    public long Id { get; set; }
+    public string EvidenceType { get; set; } = string.Empty;
+    public string FileName { get; set; } = string.Empty;
+    public string FileUrl { get; set; } = string.Empty;
+    public string? SourceSystem { get; set; }
+    public long? UploadedBy { get; set; }
+    public DateTime UploadedAt { get; set; }
+}
+
+public class AdoS8CommentDto
+{
+    public string? Remark { get; set; }
+}
+
+public class AdoS8TransferDto
+{
+    public long AssigneeId { get; set; }
+    public string? Remark { get; set; }
+}
+
+public class AdoS8AttachmentCreateDto
+{
+    public string EvidenceType { get; set; } = "file";
+    public string FileName { get; set; } = string.Empty;
+    public string FileUrl { get; set; } = string.Empty;
+    public string? SourceSystem { get; set; }
+    public long? UploadedBy { get; set; }
+}

+ 38 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8AlertRule.cs

@@ -0,0 +1,38 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_alert_rule", "S8 报警规则")]
+public class AdoS8AlertRule
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "rule_code", Length = 64)]
+    public string RuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "scene_code", Length = 64)]
+    public string SceneCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "severity", Length = 32)]
+    public string Severity { get; set; } = "MEDIUM";
+
+    [SugarColumn(ColumnName = "trigger_condition", Length = 512, IsNullable = true)]
+    public string? TriggerCondition { get; set; }
+
+    [SugarColumn(ColumnName = "threshold_val", Length = 128, IsNullable = true)]
+    public string? ThresholdVal { get; set; }
+
+    [SugarColumn(ColumnName = "time_window", Length = 64, IsNullable = true)]
+    public string? TimeWindow { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 41 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DataSource.cs

@@ -0,0 +1,41 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_data_source", "S8 数据源")]
+public class AdoS8DataSource
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "data_source_code", Length = 64)]
+    public string DataSourceCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "type", Length = 64)]
+    public string Type { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "endpoint", Length = 1024, IsNullable = true)]
+    public string? Endpoint { get; set; }
+
+    [SugarColumn(ColumnName = "auth_type", Length = 64, IsNullable = true)]
+    public string? AuthType { get; set; }
+
+    [SugarColumn(ColumnName = "enabled", ColumnDataType = "boolean")]
+    public bool Enabled { get; set; }
+
+    [SugarColumn(ColumnName = "last_check_at", IsNullable = true)]
+    public DateTime? LastCheckAt { get; set; }
+
+    [SugarColumn(ColumnName = "last_check_status", Length = 128, IsNullable = true)]
+    public string? LastCheckStatus { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 35 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DecisionRecord.cs

@@ -0,0 +1,35 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_decision_record", "S8 决策记录")]
+public class AdoS8DecisionRecord
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "exception_id", ColumnDataType = "bigint")]
+    public long ExceptionId { get; set; }
+
+    [SugarColumn(ColumnName = "decision_type", Length = 64)]
+    public string DecisionType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "decision_maker_id", ColumnDataType = "bigint")]
+    public long DecisionMakerId { get; set; }
+
+    [SugarColumn(ColumnName = "decision_maker_name", Length = 128)]
+    public string DecisionMakerName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "decision_basis", Length = 2000, IsNullable = true)]
+    public string? DecisionBasis { get; set; }
+
+    [SugarColumn(ColumnName = "decision_result", Length = 2000, IsNullable = true)]
+    public string? DecisionResult { get; set; }
+
+    [SugarColumn(ColumnName = "decision_remark", Length = 2000, IsNullable = true)]
+    public string? DecisionRemark { get; set; }
+
+    [SugarColumn(ColumnName = "decision_time")]
+    public DateTime DecisionTime { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "is_deleted", ColumnDataType = "boolean")]
+    public bool IsDeleted { get; set; }
+}

+ 32 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Evidence.cs

@@ -0,0 +1,32 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_evidence", "S8 证据附件")]
+public class AdoS8Evidence
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "exception_id", ColumnDataType = "bigint")]
+    public long ExceptionId { get; set; }
+
+    [SugarColumn(ColumnName = "evidence_type", Length = 32)]
+    public string EvidenceType { get; set; } = "file";
+
+    [SugarColumn(ColumnName = "file_name", Length = 512)]
+    public string FileName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "file_url", Length = 1024)]
+    public string FileUrl { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "source_system", Length = 128, IsNullable = true)]
+    public string? SourceSystem { get; set; }
+
+    [SugarColumn(ColumnName = "uploaded_by", ColumnDataType = "bigint", IsNullable = true)]
+    public long? UploadedBy { get; set; }
+
+    [SugarColumn(ColumnName = "uploaded_at")]
+    public DateTime UploadedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "is_deleted", ColumnDataType = "boolean")]
+    public bool IsDeleted { get; set; }
+}

+ 93 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs

@@ -0,0 +1,93 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// 异常主表(业务实体;列表为查询该表及关联展示字段,非独立「列表视图表」)。
+/// </summary>
+[SugarTable("ado_s8_exception", "S8 异常主表")]
+[SugarIndex("uk_ado_s8_exception_tenant_factory_code", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(ExceptionCode), OrderByType.Asc, IsUnique = true)]
+public class AdoS8Exception
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "exception_code", Length = 64)]
+    public string ExceptionCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "title", Length = 256)]
+    public string Title { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "description", Length = 4000, IsNullable = true)]
+    public string? Description { get; set; }
+
+    [SugarColumn(ColumnName = "scene_code", Length = 64)]
+    public string SceneCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "source_type", Length = 32)]
+    public string SourceType { get; set; } = "MANUAL";
+
+    [SugarColumn(ColumnName = "status", Length = 32)]
+    public string Status { get; set; } = "NEW";
+
+    [SugarColumn(ColumnName = "severity", Length = 32)]
+    public string Severity { get; set; } = "MEDIUM";
+
+    [SugarColumn(ColumnName = "priority_score", ColumnDataType = "decimal(10,2)")]
+    public decimal PriorityScore { get; set; }
+
+    [SugarColumn(ColumnName = "priority_level", Length = 16)]
+    public string PriorityLevel { get; set; } = "P3";
+
+    [SugarColumn(ColumnName = "occurrence_dept_id", ColumnDataType = "bigint")]
+    public long OccurrenceDeptId { get; set; }
+
+    [SugarColumn(ColumnName = "responsible_dept_id", ColumnDataType = "bigint")]
+    public long ResponsibleDeptId { get; set; }
+
+    [SugarColumn(ColumnName = "responsible_group_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ResponsibleGroupId { get; set; }
+
+    [SugarColumn(ColumnName = "assignee_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? AssigneeId { get; set; }
+
+    [SugarColumn(ColumnName = "reporter_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ReporterId { get; set; }
+
+    [SugarColumn(ColumnName = "sla_deadline", IsNullable = true)]
+    public DateTime? SlaDeadline { get; set; }
+
+    [SugarColumn(ColumnName = "timeout_flag", ColumnDataType = "boolean")]
+    public bool TimeoutFlag { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+
+    [SugarColumn(ColumnName = "assigned_at", IsNullable = true)]
+    public DateTime? AssignedAt { get; set; }
+
+    [SugarColumn(ColumnName = "closed_at", IsNullable = true)]
+    public DateTime? ClosedAt { get; set; }
+
+    [SugarColumn(ColumnName = "process_node_code", Length = 64, IsNullable = true)]
+    public string? ProcessNodeCode { get; set; }
+
+    [SugarColumn(ColumnName = "related_object_code", Length = 64, IsNullable = true)]
+    public string? RelatedObjectCode { get; set; }
+
+    [SugarColumn(ColumnName = "is_deleted", ColumnDataType = "boolean")]
+    public bool IsDeleted { get; set; }
+
+    [SugarColumn(ColumnName = "created_by", Length = 64, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnName = "updated_by", Length = 64, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+}

+ 35 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionTimeline.cs

@@ -0,0 +1,35 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_exception_timeline", "S8 异常时间线")]
+public class AdoS8ExceptionTimeline
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "exception_id", ColumnDataType = "bigint")]
+    public long ExceptionId { get; set; }
+
+    [SugarColumn(ColumnName = "action_code", Length = 64)]
+    public string ActionCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "action_label", Length = 128)]
+    public string ActionLabel { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "from_status", Length = 32, IsNullable = true)]
+    public string? FromStatus { get; set; }
+
+    [SugarColumn(ColumnName = "to_status", Length = 32, IsNullable = true)]
+    public string? ToStatus { get; set; }
+
+    [SugarColumn(ColumnName = "operator_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? OperatorId { get; set; }
+
+    [SugarColumn(ColumnName = "operator_name", Length = 128, IsNullable = true)]
+    public string? OperatorName { get; set; }
+
+    [SugarColumn(ColumnName = "action_remark", Length = 2000, IsNullable = true)]
+    public string? ActionRemark { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+}

+ 35 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8NotificationLayer.cs

@@ -0,0 +1,35 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_notification_layer", "S8 通知分层")]
+public class AdoS8NotificationLayer
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "scene_code", Length = 64)]
+    public string SceneCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "severity", Length = 32)]
+    public string Severity { get; set; } = "MEDIUM";
+
+    [SugarColumn(ColumnName = "level_code", Length = 32)]
+    public string LevelCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "target_role_ids", Length = 2000, IsNullable = true)]
+    public string? TargetRoleIds { get; set; }
+
+    [SugarColumn(ColumnName = "notify_channel", Length = 128, IsNullable = true)]
+    public string? NotifyChannel { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 29 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8NotificationLog.cs

@@ -0,0 +1,29 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// 通知发送日志(规则命中后的落库;非「列表 SQL 结果集」独立业务对象)。
+/// </summary>
+[SugarTable("ado_s8_notification_log", "S8 通知日志")]
+public class AdoS8NotificationLog
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "exception_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ExceptionId { get; set; }
+
+    [SugarColumn(ColumnName = "channel", Length = 64)]
+    public string Channel { get; set; } = "log";
+
+    [SugarColumn(ColumnName = "payload", Length = 4000, IsNullable = true)]
+    public string? Payload { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+}

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ProcessNode.cs

@@ -0,0 +1,17 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8 流程节点配置表(供异常看板"流程环节"面板使用)
+/// </summary>
+[SugarTable("ado_s8_process_node", "S8 流程节点配置")]
+public class AdoS8ProcessNode
+{
+    [SugarColumn(ColumnName = "code", IsPrimaryKey = true, Length = 64)]
+    public string Code { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "name", Length = 128)]
+    public string Name { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "sort_no")]
+    public int SortNo { get; set; }
+}

+ 29 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RolePermissionConfig.cs

@@ -0,0 +1,29 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8 角色权限配置(与系统角色表解耦的配置行;持久化对象)。
+/// </summary>
+[SugarTable("ado_s8_role_permission_config", "S8 角色权限配置")]
+public class AdoS8RolePermissionConfig
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "role_code", Length = 64)]
+    public string RoleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "permission_codes", Length = 4000, IsNullable = true)]
+    public string? PermissionCodes { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 32 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8SceneConfig.cs

@@ -0,0 +1,32 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_scene_config", "S8 场景配置")]
+public class AdoS8SceneConfig
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "scene_code", Length = 64)]
+    public string SceneCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "scene_name", Length = 256)]
+    public string SceneName { get; set; } = string.Empty;
+
+    [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; }
+}

+ 44 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs

@@ -0,0 +1,44 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+[SugarTable("ado_s8_watch_rule", "S8 监视规则")]
+public class AdoS8WatchRule
+{
+    [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; }
+
+    [SugarColumn(ColumnName = "rule_code", Length = 64)]
+    public string RuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "scene_code", Length = 64)]
+    public string SceneCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "data_source_id", ColumnDataType = "bigint")]
+    public long DataSourceId { get; set; }
+
+    [SugarColumn(ColumnName = "watch_object_type", Length = 64)]
+    public string WatchObjectType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "expression", Length = 2000, IsNullable = true)]
+    public string? Expression { get; set; }
+
+    [SugarColumn(ColumnName = "severity", Length = 32)]
+    public string Severity { get; set; } = "MEDIUM";
+
+    [SugarColumn(ColumnName = "poll_interval_seconds")]
+    public int PollIntervalSeconds { get; set; } = 300;
+
+    [SugarColumn(ColumnName = "enabled", ColumnDataType = "boolean")]
+    public bool Enabled { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMenuLinkSync.cs

@@ -14,6 +14,8 @@ public static class AidopMenuLinkSync
     private const long SysAdminRoleId = 1300000000101L;
     private const long LegacyMaterialSubstitutionMenuId = 1329003000005L;
     private const long DeprecatedMaterialSubstitutionMenuId = 1329002000004L;
+    private const long DeprecatedS8DashboardChildMenuId = 1329008000001L;
+    private const long S8DirMenuId = 1321000009000L;
 
     public static void EnsureLinked(IServiceProvider services)
     {
@@ -21,6 +23,8 @@ public static class AidopMenuLinkSync
         var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
         NormalizeMaterialSubstitutionMenu(db);
         NormalizeS1OrderWorkOrderParents(db);
+        RemoveDeprecatedS8DashboardChildMenu(db);
+        NormalizeS8MenuParents(db);
         var seedMenus = new global::Admin.NET.Plugin.AiDOP.SysMenuSeedData().HasData().ToList();
         var seedMenuIds = seedMenus.Select(m => m.Id).ToHashSet();
 
@@ -132,6 +136,41 @@ public static class AidopMenuLinkSync
         db.Deleteable<SysMenu>().Where(x => x.Id == deprecatedMenuId).ExecuteCommand();
     }
 
+    private static void RemoveDeprecatedS8DashboardChildMenu(ISqlSugarClient db)
+    {
+        const long deprecatedMenuId = DeprecatedS8DashboardChildMenuId;
+        var deprecatedExists = db.Queryable<SysMenu>().Any(m => m.Id == deprecatedMenuId);
+        if (!deprecatedExists)
+            return;
+
+        db.Deleteable<SysUserMenu>().Where(x => x.MenuId == deprecatedMenuId).ExecuteCommand();
+        db.Deleteable<SysRoleMenu>().Where(x => x.MenuId == deprecatedMenuId).ExecuteCommand();
+        db.Deleteable<SysTenantMenu>().Where(x => x.MenuId == deprecatedMenuId).ExecuteCommand();
+        db.Deleteable<SysMenu>().Where(x => x.Id == deprecatedMenuId).ExecuteCommand();
+    }
+
+    private static void NormalizeS8MenuParents(ISqlSugarClient db)
+    {
+        var s8MenuIds = new long[]
+        {
+            1329008000002L,
+            1329008000003L,
+            1329008000004L,
+            1329008000010L,
+            1329008000011L,
+            1329008000012L,
+            1329008000013L,
+            1329008000014L,
+            1329008000015L,
+            1329008000016L
+        };
+
+        db.Updateable<SysMenu>()
+            .SetColumns(x => x.Pid == S8DirMenuId)
+            .Where(x => s8MenuIds.Contains(x.Id) && x.Pid != S8DirMenuId)
+            .ExecuteCommand();
+    }
+
     /// <summary>
     /// 将 S1「订单管理」「工单管理」升级为目录并修正 path/name;
     /// 「产销协同看板」单独菜单路径为 /aidop/s1/SalesKanBan;

+ 10 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs

@@ -10,14 +10,14 @@ namespace Admin.NET.Plugin.AiDOP.Infrastructure;
 /// </summary>
 public static class AidopTenantMigration
 {
-    private static readonly string[] Tables =
+    private static readonly (string Table, string TenantColumn)[] Tables =
     {
-        "ado_smart_ops_kpi_master",
-        "ado_smart_ops_layout_item",
-        "ado_smart_ops_home_module",
-        "ado_s9_kpi_value_l1_day",
-        "ado_s9_kpi_value_l2_day",
-        "ado_s9_kpi_value_l3_day"
+        ("ado_smart_ops_kpi_master", "TenantId"),
+        ("ado_smart_ops_layout_item", "TenantId"),
+        ("ado_smart_ops_home_module", "TenantId"),
+        ("ado_s9_kpi_value_l1_day", "tenant_id"),
+        ("ado_s9_kpi_value_l2_day", "tenant_id"),
+        ("ado_s9_kpi_value_l3_day", "tenant_id")
     };
 
     public static void MigrateOldTenantId(ISqlSugarClient db)
@@ -25,17 +25,17 @@ public static class AidopTenantMigration
         var target = SqlSugarConst.DefaultTenantId;
         if (target == 1) return;
 
-        foreach (var tbl in Tables)
+        foreach (var (table, tenantColumn) in Tables)
         {
             try
             {
                 db.Ado.ExecuteCommand(
-                    $"UPDATE `{tbl}` SET `TenantId` = @target WHERE `TenantId` = 1",
+                    $"UPDATE `{table}` SET `{tenantColumn}` = @target WHERE `{tenantColumn}` = 1",
                     new { target });
             }
             catch (Exception ex)
             {
-                Trace.TraceWarning($"AidopTenantMigration [{tbl}]: {ex.Message}");
+                Trace.TraceWarning($"AidopTenantMigration [{table}]: {ex.Message}");
             }
         }
 

+ 33 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8Labels.cs

@@ -0,0 +1,33 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+public static class S8Labels
+{
+    public static string StatusLabel(string code) => code switch
+    {
+        "NEW" => "新建",
+        "ASSIGNED" => "已指派",
+        "IN_PROGRESS" => "处理中",
+        "RESOLVED" => "已处理",
+        "CLOSED" => "已关闭",
+        "REJECTED" => "已驳回",
+        "ESCALATED" => "已升级",
+        _ => code
+    };
+
+    public static string SeverityLabel(string code) => code switch
+    {
+        "CRITICAL" => "紧急",
+        "HIGH" => "高",
+        "MEDIUM" => "中",
+        "LOW" => "低",
+        _ => code
+    };
+
+    public static object[] StatusOptions() =>
+        new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "RESOLVED", "CLOSED", "REJECTED", "ESCALATED" }
+            .Select(v => new { value = v, label = StatusLabel(v) }).ToArray<object>();
+
+    public static object[] SeverityOptions() =>
+        new[] { "CRITICAL", "HIGH", "MEDIUM", "LOW" }
+            .Select(v => new { value = v, label = SeverityLabel(v) }).ToArray<object>();
+}

+ 29 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8StatusRules.cs

@@ -0,0 +1,29 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// 异常状态合法流转(与 S8 方案文档 3.5 节一致)。
+/// </summary>
+public static class S8StatusRules
+{
+    private static readonly HashSet<(string From, string To)> Edges = new()
+    {
+        ("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"),
+    };
+
+    public static bool IsAllowedTransition(string from, string to) =>
+        Edges.Contains((from, to));
+
+    public static bool IsTerminal(string status) => status == "CLOSED";
+}

+ 114 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -98,12 +98,28 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             list.Add(m);
         foreach (var m in BuildS1SalesKanbanMenus(ct))
             list.Add(m);
+        foreach (var m in BuildS8CollaborationMenus(ct))
+            list.Add(m);
 
         // 产销协同看板改为目录后,访问 /aidop/s1/SalesKanBan 默认进入指标看板子页
         var salesKanBanDir = list.FirstOrDefault(x => x.Id == 1322000000005L);
         if (salesKanBanDir != null)
             salesKanBanDir.Redirect = "/aidop/s1/SalesKanBan/kanban";
 
+        // S8:复用自动生成的首项菜单位,直接作为「异常监控看板」页,避免再出现中间层「异常管理」目录
+        var s8Dashboard = list.FirstOrDefault(x => x.Id == 1322000000028L);
+        if (s8Dashboard != null)
+        {
+            s8Dashboard.Title = "异常监控看板";
+            s8Dashboard.Path = "/aidop/s8/dashboard";
+            s8Dashboard.Name = "aidopS8Dashboard";
+            s8Dashboard.Component = "/aidop/s8/dashboard/S8DashboardPage";
+            s8Dashboard.Icon = "ele-DataBoard";
+            s8Dashboard.Type = MenuTypeEnum.Menu;
+            s8Dashboard.Redirect = null;
+            s8Dashboard.Remark = "S8 异常监控看板";
+        }
+
         return list;
     }
 
@@ -466,6 +482,103 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         }
     }
 
+    /// <summary>
+    /// S8 异常协同:直接挂在「S8 异常监控」目录下,不再经过中间层「异常管理」。
+    /// </summary>
+    private static IEnumerable<SysMenu> BuildS8CollaborationMenus(DateTime ct)
+    {
+        const long s8DirId = 1321000009000L;
+        const long baseId = 1329008000000L;
+
+        // 可见业务页(「异常监控看板」复用自动生成的 1322000000028 菜单位)
+        yield return new SysMenu
+        {
+            Id = baseId + 2,
+            Pid = s8DirId,
+            Title = "异常列表",
+            Path = "/aidop/s8/exceptions",
+            Name = "aidopS8ExceptionList",
+            Component = "/aidop/s8/exceptions/S8ExceptionListPage",
+            Icon = "ele-List",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 20,
+            Remark = "S8 异常列表"
+        };
+        yield return new SysMenu
+        {
+            Id = baseId + 3,
+            Pid = s8DirId,
+            Title = "主动提报",
+            Path = "/aidop/s8/report",
+            Name = "aidopS8ManualReport",
+            Component = "/aidop/s8/report/S8ManualReportPage",
+            Icon = "ele-EditPen",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 30,
+            Remark = "S8 主动提报"
+        };
+        yield return new SysMenu
+        {
+            Id = baseId + 4,
+            Pid = s8DirId,
+            Title = "配置中心",
+            Path = "/aidop/s8/config",
+            Name = "aidopS8ConfigHub",
+            Component = "/aidop/s8/config/S8ConfigHubPage",
+            Icon = "ele-Setting",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 40,
+            Remark = "S8 配置中心"
+        };
+
+        // 隐藏路由(不出侧栏,需参与动态路由注册)
+        yield return new SysMenu
+        {
+            Id = baseId + 10,
+            Pid = s8DirId,
+            Title = "任务详情",
+            Path = "/aidop/s8/exceptions/:id",
+            Name = "aidopS8TaskDetail",
+            Component = "/aidop/s8/exceptions/S8TaskDetailPage",
+            Icon = "ele-Document",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 90,
+            IsHide = true,
+            Remark = "S8 任务详情"
+        };
+        var cfg = new (long Off, string Path, string Name, string Title, string Component)[]
+        {
+            (11, "/aidop/s8/config/scenes", "aidopS8ScenarioConfig", "场景基础配置", "/aidop/s8/config/S8ScenarioConfigPage"),
+            (12, "/aidop/s8/config/notifications", "aidopS8NotificationLayerConfig", "通知分层配置", "/aidop/s8/config/S8NotificationLayerPage"),
+            (13, "/aidop/s8/config/roles", "aidopS8RolePermissionConfig", "角色权限配置", "/aidop/s8/config/S8RolePermissionConfigPage"),
+            (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"),
+        };
+        foreach (var (off, path, name, title, component) in cfg)
+        {
+            yield return new SysMenu
+            {
+                Id = baseId + off,
+                Pid = s8DirId,
+                Title = title,
+                Path = path,
+                Name = name,
+                Component = component,
+                Icon = "ele-Document",
+                Type = MenuTypeEnum.Menu,
+                CreateTime = ct,
+                OrderNo = 100 + (int)off,
+                IsHide = true,
+                Remark = $"S8 {title}"
+            };
+        }
+    }
+
     private static IEnumerable<SysMenu> BuildS0WarehouseMenus(DateTime ct)
     {
         const long s0DataModelingMenuId = 1322000000001L;
@@ -716,7 +829,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         }),
         ("S8", "S8 异常监控", new[]
         {
-            ("异常管理", "生产异常上报、处理、跟踪", "中", "4", ""),
+            ("异常监控看板", "生产异常监控、处理、跟踪", "中", "4", ""),
         }),
         ("S9", "S9 运营指标", new[]
         {

+ 47 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8AlertRuleService.cs

@@ -0,0 +1,47 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8AlertRuleService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8AlertRule> _rep;
+
+    public S8AlertRuleService(SqlSugarRepository<AdoS8AlertRule> rep) => _rep = rep;
+
+    public async Task<List<AdoS8AlertRule>> ListAsync(long tenantId, long factoryId) =>
+        await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .ToListAsync();
+
+    public async Task<AdoS8AlertRule> CreateAsync(AdoS8AlertRule body)
+    {
+        if (string.IsNullOrWhiteSpace(body.RuleCode) || string.IsNullOrWhiteSpace(body.SceneCode))
+            throw new S8BizException("规则编码和场景编码必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
+                           (x.RuleCode == body.RuleCode || (x.SceneCode == body.SceneCode && x.TriggerCondition == body.TriggerCondition)));
+        if (exists) throw new S8BizException("相同场景下规则编码或触发条件重复");
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8AlertRule> UpdateAsync(long id, AdoS8AlertRule body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        if (string.IsNullOrWhiteSpace(body.RuleCode) || string.IsNullOrWhiteSpace(body.SceneCode))
+            throw new S8BizException("规则编码和场景编码必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
+                           (x.RuleCode == body.RuleCode || (x.SceneCode == body.SceneCode && x.TriggerCondition == body.TriggerCondition)));
+        if (exists) 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);
+}

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8BizException.cs

@@ -0,0 +1,7 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>S8 业务规则异常,由 Controller 捕获后返回 400。</summary>
+public sealed class S8BizException : Exception
+{
+    public S8BizException(string message) : base(message) { }
+}

+ 252 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardService.cs

@@ -0,0 +1,252 @@
+using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8DashboardService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8Exception> _rep;
+    private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
+    private readonly SqlSugarRepository<AdoS8ProcessNode> _processNodeRep;
+
+    public S8DashboardService(
+        SqlSugarRepository<AdoS8Exception> rep,
+        SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
+        SqlSugarRepository<AdoS8ProcessNode> processNodeRep)
+    {
+        _rep = rep;
+        _deptRep = deptRep;
+        _processNodeRep = processNodeRep;
+    }
+
+    public async Task<object> GetOverviewAsync(long tenantId, long factoryId)
+    {
+        var q = _rep.AsQueryable().Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted);
+        var total      = await q.CountAsync();
+        var pending    = await q.CountAsync(x => x.Status == "NEW" || x.Status == "ASSIGNED" || x.Status == "IN_PROGRESS");
+        var inProgress = await q.CountAsync(x => x.Status == "IN_PROGRESS");
+        var timeout    = await q.CountAsync(x => x.TimeoutFlag);
+        var closed     = await q.CountAsync(x => x.Status == "CLOSED");
+        var todayNew   = await q.CountAsync(x => x.CreatedAt >= DateTime.Today);
+        var critical   = await q.CountAsync(x => x.Severity == "CRITICAL");
+
+        var closureRate = total > 0 ? Math.Round(closed * 100.0 / total, 1) : 0.0;
+
+        // 平均处理周期(小时),仅对已闭环且有关闭时间的记录计算
+        var closedRows = await q
+            .Where(x => x.Status == "CLOSED" && x.ClosedAt != null)
+            .Select(x => new { x.CreatedAt, x.ClosedAt })
+            .ToListAsync();
+        var avgCycleHours = closedRows.Count > 0
+            ? Math.Round(closedRows.Average(x => (x.ClosedAt!.Value - x.CreatedAt).TotalHours), 1)
+            : 0.0;
+
+        return new { total, pending, inProgress, timeout, closed, todayNew, critical, closureRate, avgCycleHours };
+    }
+
+    public async Task<object> GetTrendsAsync(long tenantId, long factoryId, int days)
+    {
+        var from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90));
+        var rows = await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from)
+            .Select(x => new { x.CreatedAt })
+            .ToListAsync();
+
+        return rows
+            .GroupBy(x => x.CreatedAt.Date)
+            .OrderBy(g => g.Key)
+            .Select(g => new { date = g.Key.ToString("yyyy-MM-dd"), count = g.Count() })
+            .ToList();
+    }
+
+    public async Task<object> GetDistributionsAsync(long tenantId, long factoryId)
+    {
+        var list = await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
+            .Select(x => new
+            {
+                x.Status,
+                x.SceneCode,
+                x.Severity,
+                x.ResponsibleDeptId,
+                x.OccurrenceDeptId,
+                x.ProcessNodeCode,
+                x.RelatedObjectCode,
+            })
+            .ToListAsync();
+
+        // 部门名称字典
+        var allDeptIds = list.Select(x => x.ResponsibleDeptId)
+            .Concat(list.Select(x => x.OccurrenceDeptId))
+            .Distinct().ToList();
+        var deptMap = await _deptRep.AsQueryable()
+            .Where(d => allDeptIds.Contains(d.Id))
+            .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
+            .ToListAsync();
+        var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString());
+
+        // 流程节点名称字典
+        var processNodes = await _processNodeRep.AsQueryable()
+            .OrderBy(p => p.SortNo)
+            .Select(p => new { p.Code, p.Name })
+            .ToListAsync();
+        var processDict = processNodes.ToDictionary(p => p.Code, p => p.Name);
+
+        return new
+        {
+            byStatus = list
+                .GroupBy(x => x.Status)
+                .Select(g => new { key = g.Key, count = g.Count() }),
+            byScene = list
+                .GroupBy(x => x.SceneCode)
+                .Select(g => new { key = g.Key, count = g.Count() }),
+            bySeverity = list
+                .GroupBy(x => x.Severity)
+                .Select(g => new { key = g.Key, count = g.Count() }),
+            byDept = list
+                .GroupBy(x => x.ResponsibleDeptId)
+                .Select(g => new
+                {
+                    key = g.Key,
+                    deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
+                    count = g.Count(),
+                }),
+            byOccurrenceDept = list
+                .GroupBy(x => x.OccurrenceDeptId)
+                .Select(g => new
+                {
+                    key = g.Key,
+                    deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
+                    count = g.Count(),
+                }),
+            byProcess = list
+                .Where(x => x.ProcessNodeCode != null)
+                .GroupBy(x => x.ProcessNodeCode!)
+                .Select(g => new
+                {
+                    key = g.Key,
+                    nodeName = processDict.GetValueOrDefault(g.Key, g.Key),
+                    count = g.Count(),
+                }),
+            byObject = list
+                .Where(x => x.RelatedObjectCode != null)
+                .GroupBy(x => x.RelatedObjectCode!)
+                .OrderByDescending(g => g.Count())
+                .Take(20)
+                .Select(g => new { key = g.Key, count = g.Count() }),
+        };
+    }
+
+    public async Task<object> GetDeptBacklogAsync(long tenantId, long factoryId)
+    {
+        var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS" };
+
+        var list = await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
+            .Select(x => new { x.ResponsibleDeptId, x.Status, x.TimeoutFlag })
+            .ToListAsync();
+
+        var deptIds = list.Select(x => x.ResponsibleDeptId).Distinct().ToList();
+        var deptMap = await _deptRep.AsQueryable()
+            .Where(d => deptIds.Contains(d.Id))
+            .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
+            .ToListAsync();
+        var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString());
+
+        return list
+            .GroupBy(x => x.ResponsibleDeptId)
+            .Select(g => new
+            {
+                deptId   = g.Key,
+                deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
+                pending    = g.Count(x => pendingStatuses.Contains(x.Status)),
+                inProgress = g.Count(x => x.Status == "IN_PROGRESS"),
+                timeout    = g.Count(x => x.TimeoutFlag),
+                total      = g.Count(),
+            })
+            .OrderByDescending(x => x.pending)
+            .ToList();
+    }
+
+    /// <summary>
+    /// 按维度返回多系列日趋势数据。dim: object | process | occDept | respDept
+    /// 返回: { dates, series: [{ name, data[] }] }
+    /// </summary>
+    public async Task<object> GetDimTrendsAsync(long tenantId, long factoryId, string dim, int days)
+    {
+        var from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90));
+
+        var rows = await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from)
+            .Select(x => new
+            {
+                x.CreatedAt,
+                x.ProcessNodeCode,
+                x.RelatedObjectCode,
+                x.OccurrenceDeptId,
+                x.ResponsibleDeptId,
+            })
+            .ToListAsync();
+
+        // 生成连续日期序列
+        var dates = Enumerable.Range(0, (DateTime.Today - from).Days + 1)
+            .Select(i => from.AddDays(i).Date)
+            .ToList();
+        var dateLabels = dates.Select(d => d.ToString("yyyy-MM-dd")).ToList();
+
+        // 按维度取 key 函数
+        Func<dynamic, string> keySelector = dim switch
+        {
+            "process"  => r => r.ProcessNodeCode ?? "未设置",
+            "occDept"  => r => r.OccurrenceDeptId.ToString(),
+            "respDept" => r => r.ResponsibleDeptId.ToString(),
+            _          => r => r.RelatedObjectCode ?? "未设置",   // object
+        };
+
+        var grouped = rows
+            .GroupBy(r => keySelector(r))
+            .OrderByDescending(g => g.Count())
+            .Take(8)    // 最多取 8 个系列,避免图例过多
+            .ToList();
+
+        // 补全部门名 / 流程节点名
+        Dictionary<string, string> nameMap = new();
+        if (dim == "process")
+        {
+            var nodes = await _processNodeRep.AsQueryable().ToListAsync();
+            nameMap = nodes.ToDictionary(n => n.Code, n => n.Name);
+        }
+        else if (dim is "occDept" or "respDept")
+        {
+            var ids = grouped.Select(g => long.TryParse(g.Key, out var id) ? id : 0).ToList();
+            var depts = await _deptRep.AsQueryable()
+                .Where(d => ids.Contains(d.Id))
+                .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
+                .ToListAsync();
+            nameMap = depts.ToDictionary(d => d.Id.ToString(), d => d.Name ?? d.Id.ToString());
+        }
+
+        var series = grouped.Select(g =>
+        {
+            var seriesName = nameMap.TryGetValue(g.Key, out var n) ? n : g.Key;
+            var byDate = g.GroupBy(r => r.CreatedAt.Date).ToDictionary(x => x.Key, x => x.Count());
+            var data = dates.Select(d => byDate.TryGetValue(d, out var c) ? c : 0).ToList();
+            return new { name = seriesName, data };
+        }).ToList();
+
+        return new { dates = dateLabels, series };
+    }
+
+    public async Task<List<AdoS8Exception>> GetQuickExceptionsAsync(long tenantId, long factoryId, string mode)
+    {
+        var q = _rep.AsQueryable().Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted);
+        var ordered = mode switch
+        {
+            "high-priority" => q.OrderBy(x => x.PriorityScore, OrderByType.Desc),
+            "timeout"       => q.Where(x => x.TimeoutFlag).OrderBy(x => x.SlaDeadline),
+            _               => q.OrderBy(x => x.CreatedAt, OrderByType.Desc),
+        };
+        return await ordered.Take(20).ToListAsync();
+    }
+}

+ 63 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DataSourceService.cs

@@ -0,0 +1,63 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8DataSourceService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8DataSource> _rep;
+
+    public S8DataSourceService(SqlSugarRepository<AdoS8DataSource> rep) => _rep = rep;
+
+    public async Task<List<AdoS8DataSource>> ListAsync(long tenantId, long factoryId) =>
+        await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .ToListAsync();
+
+    public async Task<AdoS8DataSource> CreateAsync(AdoS8DataSource body)
+    {
+        if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
+            throw new S8BizException("数据源编码和类型必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.DataSourceCode == body.DataSourceCode);
+        if (exists) throw new S8BizException("数据源编码已存在");
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8DataSource> UpdateAsync(long id, AdoS8DataSource body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
+            throw new S8BizException("数据源编码和类型必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.DataSourceCode == body.DataSourceCode);
+        if (exists) 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);
+
+    public async Task<object> TestAsync(long id)
+    {
+        var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        var success = !string.IsNullOrWhiteSpace(entity.Endpoint);
+        entity.LastCheckAt = DateTime.Now;
+        entity.LastCheckStatus = success ? "SUCCESS" : "FAILED: endpoint is empty";
+        entity.UpdatedAt = DateTime.Now;
+        await _rep.UpdateAsync(entity);
+        return new
+        {
+            id,
+            success,
+            message = success ? "连接信息校验通过" : "连接地址为空,未通过校验",
+            entity.LastCheckAt,
+            entity.LastCheckStatus
+        };
+    }
+}

+ 72 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DecisionService.cs

@@ -0,0 +1,72 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8DecisionService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
+    private readonly SqlSugarRepository<AdoS8DecisionRecord> _decisionRep;
+    private readonly SqlSugarRepository<AdoS8Evidence> _evidenceRep;
+
+    public S8DecisionService(
+        SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
+        SqlSugarRepository<AdoS8DecisionRecord> decisionRep,
+        SqlSugarRepository<AdoS8Evidence> evidenceRep)
+    {
+        _timelineRep = timelineRep;
+        _decisionRep = decisionRep;
+        _evidenceRep = evidenceRep;
+    }
+
+    public async Task<List<AdoS8TimelineItemDto>> GetTimelineAsync(long exceptionId) =>
+        await _timelineRep.AsQueryable()
+            .Where(x => x.ExceptionId == exceptionId)
+            .OrderBy(x => x.CreatedAt)
+            .Select(x => new AdoS8TimelineItemDto
+            {
+                Id = x.Id,
+                ActionCode = x.ActionCode,
+                ActionLabel = x.ActionLabel,
+                FromStatus = x.FromStatus,
+                ToStatus = x.ToStatus,
+                OperatorId = x.OperatorId,
+                OperatorName = x.OperatorName,
+                ActionRemark = x.ActionRemark,
+                CreatedAt = x.CreatedAt
+            })
+            .ToListAsync();
+
+    public async Task<List<AdoS8DecisionItemDto>> GetDecisionsAsync(long exceptionId) =>
+        await _decisionRep.AsQueryable()
+            .Where(x => x.ExceptionId == exceptionId && !x.IsDeleted)
+            .OrderBy(x => x.DecisionTime)
+            .Select(x => new AdoS8DecisionItemDto
+            {
+                Id = x.Id,
+                DecisionType = x.DecisionType,
+                DecisionMakerId = x.DecisionMakerId,
+                DecisionMakerName = x.DecisionMakerName,
+                DecisionBasis = x.DecisionBasis,
+                DecisionResult = x.DecisionResult,
+                DecisionRemark = x.DecisionRemark,
+                DecisionTime = x.DecisionTime
+            })
+            .ToListAsync();
+
+    public async Task<List<AdoS8EvidenceItemDto>> GetEvidencesAsync(long exceptionId) =>
+        await _evidenceRep.AsQueryable()
+            .Where(x => x.ExceptionId == exceptionId && !x.IsDeleted)
+            .OrderBy(x => x.UploadedAt)
+            .Select(x => new AdoS8EvidenceItemDto
+            {
+                Id = x.Id,
+                EvidenceType = x.EvidenceType,
+                FileName = x.FileName,
+                FileUrl = x.FileUrl,
+                SourceSystem = x.SourceSystem,
+                UploadedBy = x.UploadedBy,
+                UploadedAt = x.UploadedAt
+            })
+            .ToListAsync();
+}

+ 40 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DictionaryService.cs

@@ -0,0 +1,40 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8DictionaryService : ITransient
+{
+    public object? GetDictionary(string dictType) =>
+        dictType.ToUpperInvariant() switch
+        {
+            "S8_EXCEPTION_STATUS" => new[]
+            {
+                new { value = "NEW", label = "新建" },
+                new { value = "ASSIGNED", label = "已指派" },
+                new { value = "IN_PROGRESS", label = "处理中" },
+                new { value = "RESOLVED", label = "已处理" },
+                new { value = "CLOSED", label = "已关闭" },
+                new { value = "REJECTED", label = "已驳回" },
+                new { value = "ESCALATED", label = "已升级" },
+            },
+            "S8_EXCEPTION_SEVERITY" => new[]
+            {
+                new { value = "CRITICAL", label = "紧急" },
+                new { value = "HIGH", label = "高" },
+                new { value = "MEDIUM", label = "中" },
+                new { value = "LOW", label = "低" },
+            },
+            "S8_SOURCE_TYPE" => new[]
+            {
+                new { value = "MANUAL", label = "人工提报" },
+                new { value = "RULE", label = "规则触发" },
+                new { value = "SYSTEM", label = "系统自动" },
+            },
+            "S8_PRIORITY_LEVEL" => new[]
+            {
+                new { value = "P1", label = "最高优先" },
+                new { value = "P2", label = "高优先" },
+                new { value = "P3", label = "中优先" },
+                new { value = "P4", label = "低优先" },
+            },
+            _ => null
+        };
+}

+ 197 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs

@@ -0,0 +1,197 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8ExceptionService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8Exception> _rep;
+    private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
+    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
+
+    public S8ExceptionService(
+        SqlSugarRepository<AdoS8Exception> rep,
+        SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
+        SqlSugarRepository<AdoS0EmployeeMaster> empRep)
+    {
+        _rep = rep;
+        _deptRep = deptRep;
+        _empRep = empRep;
+    }
+
+    public async Task<(int total, List<AdoS8ExceptionListItemDto> list)> GetPagedAsync(AdoS8ExceptionQueryDto q)
+    {
+        (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
+        var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS" };
+
+        var query = _rep.Context.Queryable<AdoS8Exception>()
+            .LeftJoin<AdoS8SceneConfig>((e, sc) =>
+                e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode)
+            .Where((e, sc) => e.TenantId == q.TenantId && e.FactoryId == q.FactoryId && !e.IsDeleted)
+            .WhereIF(!string.IsNullOrWhiteSpace(q.Status), (e, sc) => e.Status == q.Status)
+            .WhereIF(string.IsNullOrWhiteSpace(q.Status) && q.StatusBucket == "pending",
+                (e, sc) => pendingStatuses.Contains(e.Status))
+            .WhereIF(!string.IsNullOrWhiteSpace(q.Severity), (e, sc) => e.Severity == q.Severity)
+            .WhereIF(!string.IsNullOrWhiteSpace(q.SceneCode), (e, sc) => e.SceneCode == q.SceneCode)
+            .WhereIF(q.DeptId.HasValue, (e, sc) => e.ResponsibleDeptId == q.DeptId!.Value || e.OccurrenceDeptId == q.DeptId!.Value)
+            .WhereIF(q.TimeoutFlag.HasValue, (e, sc) => e.TimeoutFlag == q.TimeoutFlag!.Value)
+            .WhereIF(q.BeginTime.HasValue, (e, sc) => e.CreatedAt >= q.BeginTime!.Value)
+            .WhereIF(q.EndTime.HasValue, (e, sc) => e.CreatedAt <= q.EndTime!.Value)
+            .WhereIF(!string.IsNullOrWhiteSpace(q.ProcessNodeCode), (e, sc) => e.ProcessNodeCode == q.ProcessNodeCode)
+            .WhereIF(!string.IsNullOrWhiteSpace(q.RelatedObjectCode), (e, sc) => e.RelatedObjectCode == q.RelatedObjectCode)
+            .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
+                (e, sc) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!));
+
+        var total = await query.CountAsync();
+        var list = await query
+            .OrderBy((e, sc) => e.CreatedAt, OrderByType.Desc)
+            .Select((e, sc) => new AdoS8ExceptionListItemDto
+            {
+                Id = e.Id,
+                ExceptionCode = e.ExceptionCode,
+                Title = e.Title,
+                Status = e.Status,
+                Severity = e.Severity,
+                PriorityScore = e.PriorityScore,
+                PriorityLevel = e.PriorityLevel,
+                SceneCode = e.SceneCode,
+                SceneName = sc.SceneName,
+                ResponsibleDeptId = e.ResponsibleDeptId,
+                AssigneeId = e.AssigneeId,
+                SlaDeadline = e.SlaDeadline,
+                TimeoutFlag = e.TimeoutFlag,
+                CreatedAt = e.CreatedAt,
+                ClosedAt = e.ClosedAt
+            })
+            .ToPageListAsync(q.Page, q.PageSize);
+
+        foreach (var r in list)
+        {
+            r.StatusLabel = S8Labels.StatusLabel(r.Status);
+            r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
+        }
+
+        await FillDisplayNamesAsync(list);
+        return (total, list);
+    }
+
+    public async Task<object> GetFilterOptionsAsync(long tenantId, long factoryId)
+    {
+        var scenes = await _rep.Context.Queryable<AdoS8SceneConfig>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled)
+            .OrderBy(x => x.SortNo)
+            .Select(x => new { value = x.SceneCode, label = x.SceneName })
+            .ToListAsync();
+        var departments = await _deptRep.AsQueryable()
+            .Where(x => x.FactoryRefId == factoryId)
+            .OrderBy(x => x.Department)
+            .Take(500)
+            .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
+            .ToListAsync();
+
+        return new
+        {
+            statuses = S8Labels.StatusOptions(),
+            severities = S8Labels.SeverityOptions(),
+            scenes,
+            departments
+        };
+    }
+
+    public async Task<AdoS8ExceptionDetailDto?> GetDetailAsync(long id, long tenantId, long factoryId)
+    {
+        var rows = await _rep.Context.Queryable<AdoS8Exception>()
+            .LeftJoin<AdoS8SceneConfig>((e, sc) =>
+                e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode)
+            .Where((e, sc) => e.Id == id && e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
+            .Select((e, sc) => new AdoS8ExceptionDetailDto
+            {
+                Id = e.Id,
+                ExceptionCode = e.ExceptionCode,
+                Title = e.Title,
+                Description = e.Description,
+                Status = e.Status,
+                Severity = e.Severity,
+                PriorityScore = e.PriorityScore,
+                PriorityLevel = e.PriorityLevel,
+                SceneCode = e.SceneCode,
+                SceneName = sc.SceneName,
+                SourceType = e.SourceType,
+                OccurrenceDeptId = e.OccurrenceDeptId,
+                ResponsibleDeptId = e.ResponsibleDeptId,
+                ResponsibleGroupId = e.ResponsibleGroupId,
+                AssigneeId = e.AssigneeId,
+                ReporterId = e.ReporterId,
+                SlaDeadline = e.SlaDeadline,
+                TimeoutFlag = e.TimeoutFlag,
+                CreatedAt = e.CreatedAt,
+                ClosedAt = e.ClosedAt,
+                AssignedAt = e.AssignedAt,
+                UpdatedAt = e.UpdatedAt
+            })
+            .Take(1)
+            .ToListAsync();
+
+        if (rows.Count == 0) return null;
+        var d = rows[0];
+        d.StatusLabel = S8Labels.StatusLabel(d.Status);
+        d.SeverityLabel = S8Labels.SeverityLabel(d.Severity);
+        await FillDisplayNamesAsync(new[] { d });
+        return d;
+    }
+
+    private async Task FillDisplayNamesAsync(IEnumerable<AdoS8ExceptionListItemDto> rows)
+    {
+        var list = rows.ToList();
+        if (list.Count == 0) return;
+
+        var deptIds = list
+            .Select(x => x.ResponsibleDeptId)
+            .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.OccurrenceDeptId))
+            .Where(x => x > 0)
+            .Distinct()
+            .ToList();
+
+        var empIds = list
+            .Select(x => x.AssigneeId ?? 0L)
+            .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.ReporterId ?? 0L))
+            .Where(x => x > 0)
+            .Distinct()
+            .ToList();
+
+        var deptMap = deptIds.Count == 0
+            ? new Dictionary<long, string>()
+            : (await _deptRep.AsQueryable()
+                .Where(x => deptIds.Contains(x.Id))
+                .Select(x => new { x.Id, Name = x.Descr ?? x.Department })
+                .ToListAsync())
+                .ToDictionary(x => x.Id, x => x.Name);
+
+        var empMap = empIds.Count == 0
+            ? new Dictionary<long, string>()
+            : (await _empRep.AsQueryable()
+                .Where(x => empIds.Contains(x.Id))
+                .Select(x => new { x.Id, Name = x.Name ?? x.Employee })
+                .ToListAsync())
+                .ToDictionary(x => x.Id, x => x.Name);
+
+        foreach (var row in list)
+        {
+            if (row is AdoS8ExceptionDetailDto detail)
+            {
+                detail.ResponsibleDeptName = deptMap.GetValueOrDefault(detail.ResponsibleDeptId);
+                detail.OccurrenceDeptName = deptMap.GetValueOrDefault(detail.OccurrenceDeptId);
+                detail.AssigneeName = detail.AssigneeId.HasValue ? empMap.GetValueOrDefault(detail.AssigneeId.Value) : null;
+                detail.ReporterName = detail.ReporterId.HasValue ? empMap.GetValueOrDefault(detail.ReporterId.Value) : null;
+            }
+            else
+            {
+                row.AssigneeName = row.AssigneeId.HasValue ? empMap.GetValueOrDefault(row.AssigneeId.Value) : null;
+            }
+        }
+    }
+}

+ 143 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -0,0 +1,143 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8ManualReportService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8Exception> _rep;
+    private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
+    private readonly SqlSugarRepository<AdoS8Evidence> _evidenceRep;
+    private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
+    private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
+    private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
+
+    public S8ManualReportService(
+        SqlSugarRepository<AdoS8Exception> rep,
+        SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
+        SqlSugarRepository<AdoS8Evidence> evidenceRep,
+        SqlSugarRepository<AdoS8SceneConfig> sceneRep,
+        SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
+        SqlSugarRepository<AdoS0LineMaster> lineRep)
+    {
+        _rep = rep;
+        _timelineRep = timelineRep;
+        _evidenceRep = evidenceRep;
+        _sceneRep = sceneRep;
+        _deptRep = deptRep;
+        _lineRep = lineRep;
+    }
+
+    public async Task<object> GetFormOptionsAsync(long tenantId, long factoryId)
+    {
+        var scenes = await _sceneRep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled)
+            .OrderBy(x => x.SortNo)
+            .Select(x => new { value = x.SceneCode, label = x.SceneName })
+            .ToListAsync();
+        var departments = await _deptRep.AsQueryable()
+            .Where(x => x.FactoryRefId == factoryId)
+            .OrderBy(x => x.Department)
+            .Take(500)
+            .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
+            .ToListAsync();
+        var lines = await _lineRep.AsQueryable()
+            .Where(x => x.FactoryRefId == factoryId)
+            .OrderBy(x => x.Line)
+            .Take(500)
+            .Select(x => new { value = x.Id, label = x.Describe ?? x.Line })
+            .ToListAsync();
+
+        return new
+        {
+            scenes,
+            severities = new[]
+            {
+                new { value = "CRITICAL", label = "紧急" },
+                new { value = "HIGH", label = "高" },
+                new { value = "MEDIUM", label = "中" },
+                new { value = "LOW", label = "低" }
+            },
+            departments,
+            lines,
+            materials = Array.Empty<object>()
+        };
+    }
+
+    public async Task<AdoS8ManualReportResultDto> CreateAsync(AdoS8ManualReportCreateDto dto)
+    {
+        if (string.IsNullOrWhiteSpace(dto.Title)) throw new S8BizException("标题必填");
+        if (string.IsNullOrWhiteSpace(dto.SceneCode)) throw new S8BizException("场景必填");
+
+        var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
+        var entity = new AdoS8Exception
+        {
+            TenantId = dto.TenantId,
+            FactoryId = dto.FactoryId,
+            ExceptionCode = code,
+            Title = dto.Title.Trim(),
+            Description = dto.Description,
+            SceneCode = dto.SceneCode.Trim(),
+            SourceType = "MANUAL",
+            Status = "NEW",
+            Severity = string.IsNullOrWhiteSpace(dto.Severity) ? "MEDIUM" : dto.Severity,
+            PriorityScore = 0,
+            PriorityLevel = "P3",
+            OccurrenceDeptId = dto.OccurrenceDeptId,
+            ResponsibleDeptId = dto.ResponsibleDeptId,
+            ReporterId = dto.ReporterId,
+            CreatedAt = DateTime.Now,
+            IsDeleted = false
+        };
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
+            await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
+            {
+                ExceptionId = entity.Id,
+                ActionCode = "CREATE",
+                ActionLabel = "创建",
+                FromStatus = null,
+                ToStatus = "NEW",
+                OperatorId = dto.ReporterId,
+                ActionRemark = "主动提报",
+                CreatedAt = DateTime.Now
+            });
+        }, ex => throw ex);
+
+        return new AdoS8ManualReportResultDto
+        {
+            ExceptionId = entity.Id,
+            ExceptionCode = entity.ExceptionCode,
+            TaskId = entity.Id
+        };
+    }
+
+    public async Task<AdoS8Exception?> GetAsync(long id) =>
+        await _rep.GetByIdAsync(id);
+
+    public async Task<AdoS8Evidence> AddAttachmentAsync(long id, AdoS8AttachmentCreateDto dto)
+    {
+        var entity = await _rep.GetFirstAsync(x => x.Id == id && !x.IsDeleted)
+            ?? throw new S8BizException("异常不存在");
+        if (string.IsNullOrWhiteSpace(dto.FileName) || string.IsNullOrWhiteSpace(dto.FileUrl))
+            throw new S8BizException("附件名称和地址必填");
+
+        var evidence = new AdoS8Evidence
+        {
+            ExceptionId = id,
+            EvidenceType = string.IsNullOrWhiteSpace(dto.EvidenceType) ? "file" : dto.EvidenceType,
+            FileName = dto.FileName.Trim(),
+            FileUrl = dto.FileUrl.Trim(),
+            SourceSystem = dto.SourceSystem,
+            UploadedBy = dto.UploadedBy,
+            UploadedAt = DateTime.Now,
+            IsDeleted = false
+        };
+        await _evidenceRep.InsertAsync(evidence);
+        return evidence;
+    }
+}

+ 42 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MasterDataAdapter.cs

@@ -0,0 +1,42 @@
+using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8MasterDataAdapter : ITransient
+{
+    private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
+    private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
+    private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
+
+    public S8MasterDataAdapter(
+        SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
+        SqlSugarRepository<AdoS0EmployeeMaster> empRep,
+        SqlSugarRepository<AdoS0LineMaster> lineRep)
+    {
+        _deptRep = deptRep;
+        _empRep = empRep;
+        _lineRep = lineRep;
+    }
+
+    public async Task<object> GetDepartmentsAsync(long? factoryRefId) =>
+        await _deptRep.AsQueryable()
+            .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
+            .Take(500)
+            .Select(x => new { id = x.Id, code = x.Department, name = x.Descr ?? x.Department })
+            .ToListAsync();
+
+    public async Task<object> GetEmployeesAsync(long? factoryRefId) =>
+        await _empRep.AsQueryable()
+            .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
+            .Take(500)
+            .Select(x => new { id = x.Id, name = x.Name ?? x.Employee, empCode = x.Employee })
+            .ToListAsync();
+
+    public async Task<object> GetLinesAsync(long? factoryRefId) =>
+        await _lineRep.AsQueryable()
+            .WhereIF(factoryRefId.HasValue, x => x.FactoryRefId == factoryRefId!.Value)
+            .Take(500)
+            .Select(x => new { id = x.Id, code = x.Line, name = x.Describe ?? x.Line })
+            .ToListAsync();
+}

+ 47 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerService.cs

@@ -0,0 +1,47 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8NotificationLayerService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8NotificationLayer> _rep;
+
+    public S8NotificationLayerService(SqlSugarRepository<AdoS8NotificationLayer> rep) => _rep = rep;
+
+    public async Task<List<AdoS8NotificationLayer>> ListAsync(long tenantId, long factoryId) =>
+        await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .ToListAsync();
+
+    public async Task<AdoS8NotificationLayer> CreateAsync(AdoS8NotificationLayer body)
+    {
+        if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
+            throw new S8BizException("场景、严重度、层级必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
+                           x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
+        if (exists) throw new S8BizException("同场景+严重度+层级已存在");
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8NotificationLayer> UpdateAsync(long id, AdoS8NotificationLayer body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.Severity) || string.IsNullOrWhiteSpace(body.LevelCode))
+            throw new S8BizException("场景、严重度、层级必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId &&
+                           x.SceneCode == body.SceneCode && x.Severity == body.Severity && x.LevelCode == body.LevelCode);
+        if (exists) 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);
+}

+ 27 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationService.cs

@@ -0,0 +1,27 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// 通知发送服务。首轮实现:将通知意图序列化为 JSON 写入 <see cref="AdoS8NotificationLog"/>,
+/// 不做实际推送。后续接入钉钉/企微/邮件时替换此服务内部实现即可,外部接口不变。
+/// </summary>
+public class S8NotificationService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8NotificationLog> _logRep;
+
+    public S8NotificationService(SqlSugarRepository<AdoS8NotificationLog> logRep) => _logRep = logRep;
+
+    public async Task SendAsync(long tenantId, long factoryId, long? exceptionId, string channel, object payload)
+    {
+        await _logRep.InsertAsync(new AdoS8NotificationLog
+        {
+            TenantId = tenantId,
+            FactoryId = factoryId,
+            ExceptionId = exceptionId,
+            Channel = channel,
+            Payload = System.Text.Json.JsonSerializer.Serialize(payload),
+            CreatedAt = DateTime.Now
+        });
+    }
+}

+ 83 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RoleConfigService.cs

@@ -0,0 +1,83 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8RoleConfigService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8RolePermissionConfig> _rep;
+    private readonly SqlSugarRepository<SysRole> _sysRoleRep;
+
+    public S8RoleConfigService(
+        SqlSugarRepository<AdoS8RolePermissionConfig> rep,
+        SqlSugarRepository<SysRole> sysRoleRep)
+    {
+        _rep = rep;
+        _sysRoleRep = sysRoleRep;
+    }
+
+    public async Task<List<AdoS8RolePermissionConfig>> ListAsync(long tenantId, long factoryId) =>
+        await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .ToListAsync();
+
+    public async Task<AdoS8RolePermissionConfig> CreateAsync(AdoS8RolePermissionConfig body)
+    {
+        if (string.IsNullOrWhiteSpace(body.RoleCode)) throw new S8BizException("角色编码必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.RoleCode == body.RoleCode);
+        if (exists) throw new S8BizException("角色编码已存在");
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8RolePermissionConfig> UpdateAsync(long id, AdoS8RolePermissionConfig body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        if (string.IsNullOrWhiteSpace(body.RoleCode)) throw new S8BizException("角色编码必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.RoleCode == body.RoleCode);
+        if (exists) 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);
+
+    /// <summary>
+    /// 从系统角色表一键导入,跳过已存在的角色编码,返回本次新增条数。
+    /// </summary>
+    public async Task<int> ImportFromSysRolesAsync(long tenantId, long factoryId)
+    {
+        var sysRoles = await _sysRoleRep.AsQueryable()
+            .Where(r => r.TenantId == tenantId && r.Status == StatusEnum.Enable)
+            .Where(r => !string.IsNullOrEmpty(r.Code))
+            .Select(r => new { r.Code, r.Name })
+            .ToListAsync();
+
+        var existingCodes = (await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .Select(x => x.RoleCode)
+            .ToListAsync()).ToHashSet();
+
+        var toInsert = sysRoles
+            .Where(r => !existingCodes.Contains(r.Code!))
+            .Select(r => new AdoS8RolePermissionConfig
+            {
+                TenantId = tenantId,
+                FactoryId = factoryId,
+                RoleCode = r.Code!,
+                PermissionCodes = "[]",
+                CreatedAt = DateTime.Now
+            }).ToList();
+
+        if (toInsert.Count > 0)
+            await _rep.InsertRangeAsync(toInsert);
+
+        return toInsert.Count;
+    }
+}

+ 46 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8SceneConfigService.cs

@@ -0,0 +1,46 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8SceneConfigService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8SceneConfig> _rep;
+
+    public S8SceneConfigService(SqlSugarRepository<AdoS8SceneConfig> rep) => _rep = rep;
+
+    public async Task<List<AdoS8SceneConfig>> ListAsync(long tenantId, long factoryId) =>
+        await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .OrderBy(x => x.SortNo)
+            .ToListAsync();
+
+    public async Task<AdoS8SceneConfig> CreateAsync(AdoS8SceneConfig body)
+    {
+        if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.SceneName))
+            throw new S8BizException("场景编码和名称必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.SceneCode == body.SceneCode);
+        if (exists) throw new S8BizException("场景编码已存在");
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8SceneConfig> UpdateAsync(long id, AdoS8SceneConfig body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        if (string.IsNullOrWhiteSpace(body.SceneCode) || string.IsNullOrWhiteSpace(body.SceneName))
+            throw new S8BizException("场景编码和名称必填");
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.SceneCode == body.SceneCode);
+        if (exists) 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);
+}

+ 141 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -0,0 +1,141 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8TaskFlowService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8Exception> _rep;
+    private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
+
+    public S8TaskFlowService(
+        SqlSugarRepository<AdoS8Exception> rep,
+        SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep)
+    {
+        _rep = rep;
+        _timelineRep = timelineRep;
+    }
+
+    public async Task<AdoS8Exception> ClaimAsync(long id, long tenantId, long factoryId, long assigneeId, string? remark)
+    {
+        if (assigneeId <= 0) throw new S8BizException("认领需指定处理人 AssigneeId");
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "ASSIGNED"))
+            throw new S8BizException($"状态 {e.Status} 不可认领");
+
+        var fromStatus = e.Status;
+        e.Status = "ASSIGNED";
+        e.AssigneeId = assigneeId;
+        e.AssignedAt = DateTime.Now;
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", assigneeId, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+
+    public async Task<AdoS8Exception> TransferAsync(long id, long tenantId, long factoryId, long newAssigneeId, string? remark)
+    {
+        if (newAssigneeId <= 0) throw new S8BizException("转派目标无效");
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (S8StatusRules.IsTerminal(e.Status)) throw new S8BizException("已关闭不可转派");
+
+        e.AssigneeId = newAssigneeId;
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "TRANSFER", "转派", e.Status, e.Status, newAssigneeId, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+
+    public async Task<AdoS8Exception> UpgradeAsync(long id, long tenantId, long factoryId, string? remark)
+    {
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "ESCALATED"))
+            throw new S8BizException($"状态 {e.Status} 不可升级");
+
+        var from = e.Status;
+        e.Status = "ESCALATED";
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "UPGRADE", "升级", from, "ESCALATED", null, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+
+    public async Task<AdoS8Exception> RejectAsync(long id, long tenantId, long factoryId, string? remark)
+    {
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "REJECTED"))
+            throw new S8BizException($"状态 {e.Status} 不可驳回");
+
+        var from = e.Status;
+        e.Status = "REJECTED";
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "REJECT", "驳回", from, "REJECTED", null, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+
+    public async Task<AdoS8Exception> CloseAsync(long id, long tenantId, long factoryId, string? remark)
+    {
+        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "CLOSED"))
+            throw new S8BizException($"状态 {e.Status} 不可关闭");
+
+        var from = e.Status;
+        e.Status = "CLOSED";
+        e.ClosedAt = DateTime.Now;
+        e.UpdatedAt = DateTime.Now;
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            await _rep.UpdateAsync(e);
+            await InsertTimelineAsync(e.Id, "CLOSE", "关闭", from, "CLOSED", null, null, remark);
+        }, ex => throw ex);
+
+        return e;
+    }
+
+    public async Task CommentAsync(long id, string? remark)
+    {
+        var e = await _rep.GetFirstAsync(x => x.Id == id && !x.IsDeleted)
+            ?? throw new S8BizException("异常不存在");
+        await InsertTimelineAsync(e.Id, "COMMENT", "补充说明", e.Status, e.Status, null, null, remark);
+    }
+
+    private Task<AdoS8Exception?> LoadAsync(long id, long tenantId, long factoryId) =>
+        _rep.GetFirstAsync(x => x.Id == id && x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted);
+
+    private async Task InsertTimelineAsync(long exceptionId, string code, string label, string? from, string? to,
+        long? operatorId, string? operatorName, string? remark) =>
+        await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
+        {
+            ExceptionId = exceptionId,
+            ActionCode = code,
+            ActionLabel = label,
+            FromStatus = from,
+            ToStatus = to,
+            OperatorId = operatorId,
+            OperatorName = operatorName,
+            ActionRemark = remark,
+            CreatedAt = DateTime.Now
+        });
+}

+ 74 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -0,0 +1,74 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+public class S8WatchRuleService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8WatchRule> _rep;
+    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+    private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
+
+    public S8WatchRuleService(
+        SqlSugarRepository<AdoS8WatchRule> rep,
+        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
+        SqlSugarRepository<AdoS8SceneConfig> sceneRep)
+    {
+        _rep = rep;
+        _dataSourceRep = dataSourceRep;
+        _sceneRep = sceneRep;
+    }
+
+    public async Task<List<AdoS8WatchRule>> ListAsync(long tenantId, long factoryId) =>
+        await _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .ToListAsync();
+
+    public async Task<AdoS8WatchRule> CreateAsync(AdoS8WatchRule body)
+    {
+        await ValidateAsync(body);
+        body.Id = 0;
+        body.CreatedAt = DateTime.Now;
+        await _rep.InsertAsync(body);
+        return body;
+    }
+
+    public async Task<AdoS8WatchRule> UpdateAsync(long id, AdoS8WatchRule body)
+    {
+        var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        await ValidateAsync(body, id);
+        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);
+
+    public async Task<object> TestAsync(long id)
+    {
+        var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        await ValidateAsync(entity, id);
+        return new { id, success = true, message = "规则基础校验通过", pollIntervalSeconds = entity.PollIntervalSeconds };
+    }
+
+    private async Task ValidateAsync(AdoS8WatchRule body, long? id = null)
+    {
+        if (string.IsNullOrWhiteSpace(body.RuleCode) || string.IsNullOrWhiteSpace(body.SceneCode))
+            throw new S8BizException("规则编码和场景编码必填");
+
+        var exists = await _rep.AsQueryable()
+            .AnyAsync(x => x.Id != (id ?? 0) && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.RuleCode == body.RuleCode);
+        if (exists) throw new S8BizException("监视规则编码已存在");
+
+        var dataSource = await _dataSourceRep.GetFirstAsync(x => x.Id == body.DataSourceId)
+            ?? throw new S8BizException("关联数据源不存在");
+        if (!dataSource.Enabled) throw new S8BizException("关联数据源未启用");
+
+        var scene = await _sceneRep.GetFirstAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.SceneCode == body.SceneCode)
+            ?? throw new S8BizException("关联场景不存在");
+        if (!scene.Enabled) throw new S8BizException("关联场景未启用");
+
+        if (body.PollIntervalSeconds <= 0) throw new S8BizException("轮询间隔必须大于 0");
+    }
+}

+ 38 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -0,0 +1,38 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// 监视规则轮询调度服务(首轮存根)。
+/// 后续接入 Admin.NET 定时任务机制后,由调度器周期调用 <see cref="RunOnceAsync"/>,
+/// 按各规则的 PollIntervalSeconds 逐条评估并生成异常记录。
+/// </summary>
+public class S8WatchSchedulerService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
+    private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
+    private readonly S8NotificationService _notificationService;
+
+    public S8WatchSchedulerService(
+        SqlSugarRepository<AdoS8WatchRule> ruleRep,
+        SqlSugarRepository<AdoS8Exception> exceptionRep,
+        S8NotificationService notificationService)
+    {
+        _ruleRep = ruleRep;
+        _exceptionRep = exceptionRep;
+        _notificationService = notificationService;
+    }
+
+    /// <summary>
+    /// 单次轮询入口。当前为存根,返回已启用规则数量,不做实际数据采集。
+    /// </summary>
+    public async Task<int> RunOnceAsync()
+    {
+        var enabledRules = await _ruleRep.AsQueryable()
+            .Where(x => x.Enabled)
+            .CountAsync();
+
+        // TODO: 逐条评估规则、生成异常、触发通知
+        return enabledRules;
+    }
+}

+ 13 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -3,6 +3,7 @@ using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Sales;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Supply;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Order;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Microsoft.AspNetCore.Builder;
@@ -93,6 +94,17 @@ public class Startup : AppStartup
                 typeof(AdoS0ImageType),
                 typeof(AdoS0QualitySegmentImage),
                 typeof(AdoS0SrmPurchase),
+                typeof(AdoS8Exception),
+                typeof(AdoS8ExceptionTimeline),
+                typeof(AdoS8DecisionRecord),
+                typeof(AdoS8Evidence),
+                typeof(AdoS8SceneConfig),
+                typeof(AdoS8AlertRule),
+                typeof(AdoS8NotificationLayer),
+                typeof(AdoS8DataSource),
+                typeof(AdoS8WatchRule),
+                typeof(AdoS8RolePermissionConfig),
+                typeof(AdoS8NotificationLog),
                 typeof(ContractReview),
                 typeof(ContractReviewFlow),
                 typeof(ProductDesign),
@@ -102,7 +114,7 @@ public class Startup : AppStartup
         }
         catch (Exception ex)
         {
-            Trace.TraceWarning("Ai-DOP Development CodeFirst (S0/Order demo tables): " + ex);
+            Trace.TraceWarning("Ai-DOP Development CodeFirst (S0/S8/Order demo tables): " + ex);
         }
     }
 }