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

feat(s8): add exception type tree hierarchy

YY968XX 1 день назад
Родитель
Сommit
d01a167939

+ 1 - 1
Web/package.json

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

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

@@ -34,6 +34,7 @@ export interface S8ExceptionTypeConfigRow {
 	ownerRoleCode?: string | null;
 	escalateRoleCode?: string | null;
 	statsMode: string;
+	monitoringCategoryKey?: string | null;
 	mobileVisible: boolean;
 	icon?: string | null;
 	enabled: boolean;
@@ -41,10 +42,23 @@ export interface S8ExceptionTypeConfigRow {
 	remark?: string | null;
 	createdAt: string;
 	updatedAt?: string | null;
+	// TASK-015-TREE-DEV-1:2 层树字段。父节点 isGroup=true 仅作为分组容器;子节点 parentId 指向父节点 id。
+	parentId?: number | null;
+	isGroup?: boolean;
 }
 
 export type S8ExceptionTypeConfigPayload = Omit<S8ExceptionTypeConfigRow, 'id' | 'createdAt' | 'updatedAt'>;
 
+// TASK-015-TREE-DEV-1:异常类型树节点;children 仅在父节点上有值。
+export interface S8ExceptionTypeTreeNode extends S8ExceptionTypeConfigRow {
+	children: S8ExceptionTypeTreeNode[];
+}
+
+export interface S8ExceptionTypeTreeResult {
+	roots: S8ExceptionTypeTreeNode[];
+	orphans: S8ExceptionTypeTreeNode[];
+}
+
 export interface S8DashboardCellConfigRow {
 	id: number;
 	tenantId: number;
@@ -238,6 +252,9 @@ export const s8ConfigApi = {
 	exceptionTypes: {
 		list: (params?: { tenantId?: number; factoryId?: number; enabledOnly?: boolean }) =>
 			service.get<S8ExceptionTypeConfigRow[]>('/api/aidop/s8/config/exception-types', { params }).then(unwrap),
+		// TASK-015-TREE-DEV-1:2 层树形结构。enabledOnly 默认 true,与 backend 默认一致。
+		tree: (params?: { tenantId?: number; factoryId?: number; enabledOnly?: boolean }) =>
+			service.get<S8ExceptionTypeTreeResult>('/api/aidop/s8/config/exception-types/tree', { params }).then(unwrap),
 		detail: (typeCode: string, params?: { tenantId?: number; factoryId?: number }) =>
 			service.get<S8ExceptionTypeConfigRow>(`/api/aidop/s8/config/exception-types/${typeCode}`, { params }).then(unwrap),
 		create: (body: S8ExceptionTypeConfigPayload) =>

+ 400 - 73
Web/src/views/aidop/s8/config/S8ExceptionTypeConfigPage.vue

@@ -1,17 +1,24 @@
 <script setup lang="ts" name="aidopS8ExceptionTypeConfig">
-import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
-import { s8ConfigApi } from '../api/s8ConfigApi';
-
-// S8-CONFIG-CLEANUP-DEMO-1:列表只展示生效字段;不展示 severityDefault / slaMinutes / ownerRoleCode。
-// 这些列在 entity / DB / API / fields / buildDefault 全部保留,仅列表渲染层不显示。
-const columns = [
-	{ key: 'typeCode', label: '类型编码', width: 180 },
-	{ key: 'typeName', label: '类型名称', width: 180 },
-	{ key: 'sceneCode', label: '场景编码', width: 150 },
-	{ key: 'monitoringCategoryKey', label: '大屏统计类别', width: 160 },
-	{ key: 'enabled', label: '启用', width: 90 },
-	{ key: 'sortNo', label: '排序', width: 90 },
-];
+// TASK-015-TREE-DEV-1:异常类型管理页改为 2 层树形结构。
+// - 顶层 5 个真实分组父节点(isGroup=true)由 SeedData 维护,不可编辑/删除/切换启用,仅作展开容器。
+// - 子节点(isGroup=false)继续支持新建 / 编辑 / 启用 / 删除。
+// - 未归类(parentId=null && isGroup=false)由前端组装虚拟根 __VIRTUAL_UNCLASSIFIED__,DB 内无真实未归类父节点。
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '../components/AidopDemoShell.vue';
+import {
+	s8ConfigApi,
+	type S8ExceptionTypeConfigPayload,
+	type S8ExceptionTypeConfigRow,
+	type S8ExceptionTypeTreeNode,
+} from '../api/s8ConfigApi';
+
+const VIRTUAL_UNCLASSIFIED_CODE = '__VIRTUAL_UNCLASSIFIED__';
+
+interface TreeRow extends S8ExceptionTypeConfigRow {
+	children?: TreeRow[];
+	isVirtual?: boolean;
+}
 
 const MONITORING_CATEGORY_OPTIONS = [
 	{ label: '订单评审', value: 'ORDER_REVIEW' },
@@ -21,48 +28,35 @@ const MONITORING_CATEGORY_OPTIONS = [
 	{ label: '总装发货', value: 'FINAL_ASSEMBLY_DELIVERY' },
 ];
 
-// S8-CONFIG-CLEANUP-DEMO-1:以下 7 个字段标记 hidden=true:
-// severityDefault / slaMinutes / ownerRoleCode / escalateRoleCode / statsMode / icon / mobileVisible
-// 渲染层隐藏,但保留在 fields / buildDefault / form / PUT payload,旧值原样回写不丢。
-// required 字段一并去掉以避免新建场景必填校验阻塞(实际值由 buildDefault 提供默认)。
-const fields = [
-	{ key: 'typeCode', label: '类型编码', type: 'input', required: true, placeholder: '如 ORDER_CHANGE' },
-	{ key: 'typeName', label: '类型名称', type: 'input', required: true },
-	{ key: 'sceneCode', label: '场景编码', type: 'select', required: true, optionsKey: 'scenes' },
-	{ key: 'monitoringCategoryKey', label: '大屏统计类别', type: 'select', options: MONITORING_CATEGORY_OPTIONS },
-	{ key: 'severityDefault', label: '默认严重度', type: 'select', optionsKey: 'severities', hidden: true },
-	{ key: 'slaMinutes', label: 'SLA(分钟)', type: 'number', hidden: true },
-	{ key: 'ownerRoleCode', label: '责任角色', type: 'select', optionsKey: 'roles', hidden: true },
-	{ key: 'escalateRoleCode', label: '升级角色', type: 'select', optionsKey: 'roles', hidden: true },
-	{
-		key: 'statsMode',
-		label: '统计模式',
-		type: 'select',
-		options: [
-			{ label: '全部', value: 'ALL' },
-			{ label: '频率', value: 'FREQUENCY' },
-			{ label: '时长', value: 'DURATION' },
-			{ label: '关闭率', value: 'CLOSE_RATE' },
-		],
-		hidden: true,
-	},
-	{ key: 'mobileVisible', label: '移动端展示', type: 'switch', hidden: true },
-	{ key: 'enabled', label: '启用', type: 'switch' },
-	{ key: 'sortNo', label: '排序', type: 'number' },
-	{ key: 'icon', label: '图标标识', type: 'input', hidden: true },
-	{ key: 'remark', label: '备注', type: 'textarea' },
-] as const;
-
-const buildDefault = () => ({
+const SCENE_ORDER = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7'];
+
+const loading = ref(false);
+const treeRows = ref<TreeRow[]>([]);
+const parentOptions = ref<{ id: number; typeCode: string; typeName: string }[]>([]);
+const sceneOptions = ref<{ label: string; value: string }[]>([]);
+const roleOptions = ref<{ label: string; value: string }[]>([]);
+
+const dialogVisible = ref(false);
+const dialogMode = ref<'create' | 'edit'>('create');
+const editingId = ref<number | null>(null);
+const saving = ref(false);
+
+const SEVERITY_OPTIONS = [
+	{ value: 'FOLLOW', label: '关注' },
+	{ value: 'SERIOUS', label: '严重' },
+];
+
+const form = reactive({
 	typeCode: '',
 	typeName: '',
 	sceneCode: '',
-	monitoringCategoryKey: '',
+	parentId: null as number | null,
 	severityDefault: 'FOLLOW',
 	slaMinutes: 60,
 	ownerRoleCode: '',
 	escalateRoleCode: '',
 	statsMode: 'ALL',
+	monitoringCategoryKey: '',
 	mobileVisible: true,
 	enabled: true,
 	sortNo: 0,
@@ -70,28 +64,30 @@ const buildDefault = () => ({
 	remark: '',
 });
 
-async function loadOptions() {
-	const [scenes, severities, roles] = await Promise.all([
-		s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 }),
-		s8ConfigApi.severities(),
-		s8ConfigApi.list('/api/aidop/s8/config/roles', { tenantId: 1, factoryId: 1 }),
-	]);
-
-	return {
-		scenes: scenes.map((item: any) => ({ label: `${item.sceneName} (${item.sceneCode})`, value: item.sceneCode })),
-		severities,
-		roles: roles.map((item: any) => ({ label: item.roleCode, value: item.roleCode })),
-	};
+function resetForm() {
+	form.typeCode = '';
+	form.typeName = '';
+	form.sceneCode = '';
+	form.parentId = null;
+	form.severityDefault = 'FOLLOW';
+	form.slaMinutes = 60;
+	form.ownerRoleCode = '';
+	form.escalateRoleCode = '';
+	form.statsMode = 'ALL';
+	form.monitoringCategoryKey = '';
+	form.mobileVisible = true;
+	form.enabled = true;
+	form.sortNo = 0;
+	form.icon = '';
+	form.remark = '';
 }
 
-// S8-CONFIG-POLISH-SCENE-RULE-LIST-1:列表按 S1→S7 / sort_no / type_code 稳定排序。
-// 仅展示层排序,不参与保存 payload;未在序列内的 scene 排到最末。
-const SCENE_ORDER = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7'];
 function sceneIdx(code: unknown) {
 	const i = SCENE_ORDER.indexOf(String(code ?? ''));
 	return i >= 0 ? i : SCENE_ORDER.length;
 }
-function sortRows(rows: readonly Record<string, unknown>[]): Record<string, unknown>[] {
+
+function sortChildren<T extends { sceneCode?: string; sortNo?: number; typeCode?: string }>(rows: T[]): T[] {
 	return [...rows].sort((a, b) => {
 		const sa = sceneIdx(a.sceneCode);
 		const sb = sceneIdx(b.sceneCode);
@@ -102,17 +98,348 @@ function sortRows(rows: readonly Record<string, unknown>[]): Record<string, unkn
 		return String(a.typeCode ?? '').localeCompare(String(b.typeCode ?? ''));
 	});
 }
+
+function categoryLabel(key?: string | null): string {
+	if (!key) return '';
+	return MONITORING_CATEGORY_OPTIONS.find((o) => o.value === key)?.label ?? key;
+}
+
+async function loadTree() {
+	loading.value = true;
+	try {
+		// 管理页需要看到 disabled 子节点,因此 enabledOnly=false。
+		const tree = await s8ConfigApi.exceptionTypes.tree({ tenantId: 1, factoryId: 1, enabledOnly: false });
+		const roots: TreeRow[] = (tree.roots ?? []).map((r) => ({
+			...(r as S8ExceptionTypeConfigRow),
+			children: sortChildren((r.children ?? []) as TreeRow[]),
+		}));
+
+		// 未归类虚拟根:仅当存在 orphan 子节点时呈现,避免空根扰动 UI。
+		const orphans = (tree.orphans ?? []).map((o) => ({ ...(o as S8ExceptionTypeConfigRow) })) as TreeRow[];
+		const sortedRoots = sortChildren(roots) as TreeRow[];
+		if (orphans.length > 0) {
+			sortedRoots.push({
+				id: -1,
+				tenantId: 0,
+				factoryId: 0,
+				typeCode: VIRTUAL_UNCLASSIFIED_CODE,
+				typeName: '未归类',
+				sceneCode: '',
+				severityDefault: '',
+				slaMinutes: 0,
+				statsMode: '',
+				mobileVisible: false,
+				enabled: true,
+				sortNo: 9999,
+				parentId: null,
+				isGroup: true,
+				isVirtual: true,
+				children: sortChildren(orphans) as TreeRow[],
+				createdAt: '',
+			});
+		}
+		treeRows.value = sortedRoots;
+
+		parentOptions.value = (tree.roots ?? []).map((r) => ({
+			id: Number(r.id),
+			typeCode: r.typeCode,
+			typeName: r.typeName,
+		}));
+	} catch (e: any) {
+		ElMessage.error(e?.message ?? '加载异常类型失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadOptions() {
+	try {
+		const [scenes, roles] = await Promise.all([
+			s8ConfigApi.scenes({ tenantId: 1, factoryId: 1 }),
+			s8ConfigApi.list('/api/aidop/s8/config/roles', { tenantId: 1, factoryId: 1 }),
+		]);
+		sceneOptions.value = (scenes as any[]).map((it) => ({
+			label: `${it.sceneName} (${it.sceneCode})`,
+			value: it.sceneCode,
+		}));
+		roleOptions.value = (roles as any[]).map((it) => ({ label: it.roleCode, value: it.roleCode }));
+	} catch (e: any) {
+		ElMessage.warning('部分下拉选项加载失败');
+	}
+}
+
+function openCreate(parentId: number | null) {
+	resetForm();
+	form.parentId = parentId;
+	if (parentId != null) {
+		const parent = parentOptions.value.find((p) => p.id === parentId);
+		// 子节点新建:以父节点 monitoringCategoryKey 自动回填。
+		const parentRow = treeRows.value.find((r) => r.id === parentId);
+		if (parentRow?.monitoringCategoryKey) form.monitoringCategoryKey = parentRow.monitoringCategoryKey;
+		// 备注里提示父节点编码,避免错放。
+		form.remark = parent ? `归属:${parent.typeName}` : '';
+	}
+	dialogMode.value = 'create';
+	editingId.value = null;
+	dialogVisible.value = true;
+}
+
+function openEdit(row: TreeRow) {
+	if (row.isGroup || row.isVirtual) return;
+	resetForm();
+	form.typeCode = row.typeCode;
+	form.typeName = row.typeName;
+	form.sceneCode = row.sceneCode;
+	form.parentId = row.parentId ?? null;
+	form.severityDefault = row.severityDefault || 'FOLLOW';
+	form.slaMinutes = row.slaMinutes;
+	form.ownerRoleCode = row.ownerRoleCode ?? '';
+	form.escalateRoleCode = row.escalateRoleCode ?? '';
+	form.statsMode = row.statsMode || 'ALL';
+	form.monitoringCategoryKey = row.monitoringCategoryKey ?? '';
+	form.mobileVisible = row.mobileVisible;
+	form.enabled = row.enabled;
+	form.sortNo = row.sortNo;
+	form.icon = row.icon ?? '';
+	form.remark = row.remark ?? '';
+	dialogMode.value = 'edit';
+	editingId.value = row.id;
+	dialogVisible.value = true;
+}
+
+async function handleSave() {
+	if (!form.typeCode.trim() || !form.typeName.trim()) {
+		ElMessage.warning('类型编码和名称必填');
+		return;
+	}
+	if (!form.sceneCode) {
+		ElMessage.warning('请选择场景编码');
+		return;
+	}
+	if (dialogMode.value === 'create' && form.parentId == null) {
+		ElMessage.warning('请选择所属分组父节点');
+		return;
+	}
+	const payload: S8ExceptionTypeConfigPayload = {
+		tenantId: 1,
+		factoryId: 1,
+		typeCode: form.typeCode.trim().toUpperCase().replace(/\s+/g, ''),
+		typeName: form.typeName.trim(),
+		sceneCode: form.sceneCode,
+		severityDefault: form.severityDefault,
+		slaMinutes: Number(form.slaMinutes) || 0,
+		ownerRoleCode: form.ownerRoleCode || null,
+		escalateRoleCode: form.escalateRoleCode || null,
+		statsMode: form.statsMode || 'ALL',
+		monitoringCategoryKey: form.monitoringCategoryKey || null,
+		mobileVisible: form.mobileVisible,
+		enabled: form.enabled,
+		sortNo: Number(form.sortNo) || 0,
+		icon: form.icon || null,
+		remark: form.remark || null,
+		parentId: form.parentId,
+		isGroup: false,
+	};
+	saving.value = true;
+	try {
+		if (dialogMode.value === 'create') {
+			await s8ConfigApi.exceptionTypes.create(payload);
+			ElMessage.success('新增异常类型成功');
+		} else if (editingId.value != null) {
+			await s8ConfigApi.exceptionTypes.update(editingId.value, payload);
+			ElMessage.success('保存成功');
+		}
+		dialogVisible.value = false;
+		await loadTree();
+	} catch (e: any) {
+		const msg = e?.response?.data?.message ?? e?.message ?? '保存失败';
+		ElMessage.error(msg);
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function handleToggleEnabled(row: TreeRow, next: boolean) {
+	if (row.isGroup || row.isVirtual) return;
+	try {
+		await s8ConfigApi.exceptionTypes.setEnabled(row.id, next);
+		row.enabled = next;
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? '切换启用状态失败');
+	}
+}
+
+async function handleDelete(row: TreeRow) {
+	if (row.isGroup || row.isVirtual) return;
+	try {
+		await ElMessageBox.confirm(`确认删除异常类型「${row.typeName}」?`, '删除确认', { type: 'warning' });
+	} catch {
+		return;
+	}
+	try {
+		await s8ConfigApi.exceptionTypes.remove(row.id);
+		ElMessage.success('删除成功');
+		await loadTree();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? '删除失败');
+	}
+}
+
+const parentLabelOfForm = computed(() => {
+	if (form.parentId == null) return '—';
+	return parentOptions.value.find((p) => p.id === form.parentId)?.typeName ?? '—';
+});
+
+onMounted(async () => {
+	await Promise.all([loadTree(), loadOptions()]);
+});
 </script>
 
 <template>
-	<S8CrudConfigPage
-		title="异常类型配置"
-		subtitle="S8 / 配置 / 异常类型"
-		endpoint="/api/aidop/s8/config/exception-types"
-		:columns="columns"
-		:fields="fields"
-		:build-default="buildDefault"
-		:load-options="loadOptions"
-		:sort-rows="sortRows"
-	/>
+	<AidopDemoShell title="异常类型配置" subtitle="S8 / 配置 / 异常类型(2 层树形)">
+		<div class="mb12">
+			<el-button type="primary" :disabled="parentOptions.length === 0" @click="openCreate(null)">新建子类型</el-button>
+			<span class="config-hint">新建时需选择所属分组父节点;分组父节点由 SeedData 维护,无法在此页编辑。</span>
+		</div>
+
+		<el-table
+			v-loading="loading"
+			:data="treeRows"
+			row-key="typeCode"
+			border
+			stripe
+			default-expand-all
+			:tree-props="{ children: 'children' }"
+		>
+			<el-table-column prop="typeName" label="类型名称" min-width="220" show-overflow-tooltip>
+				<template #default="{ row }">
+					<el-tag v-if="row.isGroup" type="info" size="small" effect="plain" class="config-tag">分组</el-tag>
+					<span>{{ row.typeName }}</span>
+					<el-tag v-if="row.isVirtual" type="warning" size="small" effect="plain" class="config-tag">虚拟根</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="typeCode" label="类型编码" width="220" show-overflow-tooltip />
+			<el-table-column label="大屏统计类别" width="140">
+				<template #default="{ row }">{{ categoryLabel(row.monitoringCategoryKey) || '—' }}</template>
+			</el-table-column>
+			<el-table-column label="场景" width="100">
+				<template #default="{ row }">
+					<span v-if="row.isGroup || row.isVirtual">—</span>
+					<span v-else>{{ row.sceneCode || '—' }}</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="启用" width="90" align="center">
+				<template #default="{ row }">
+					<el-switch
+						v-if="!row.isGroup && !row.isVirtual"
+						:model-value="row.enabled"
+						@change="(v: any) => handleToggleEnabled(row, !!v)"
+					/>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column prop="sortNo" label="排序" width="80">
+				<template #default="{ row }">{{ row.isVirtual ? '—' : row.sortNo }}</template>
+			</el-table-column>
+			<el-table-column label="操作" width="240">
+				<template #default="{ row }">
+					<template v-if="row.isGroup && !row.isVirtual">
+						<el-button size="small" type="primary" link @click="openCreate(row.id)">在此分组下新建</el-button>
+					</template>
+					<template v-else-if="row.isVirtual">
+						<span class="config-hint">未归类节点无法在此新增;调整子节点 parentId 后将自动归类。</span>
+					</template>
+					<template v-else>
+						<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
+						<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
+					</template>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<el-dialog
+			v-model="dialogVisible"
+			:title="dialogMode === 'create' ? '新建子类型' : '编辑子类型'"
+			width="640px"
+			:close-on-click-modal="false"
+			destroy-on-close
+		>
+			<el-form label-width="120px" size="small">
+				<el-form-item label="所属父节点" required>
+					<el-select v-model="form.parentId" placeholder="选择分组父节点" filterable style="width: 100%">
+						<el-option v-for="p in parentOptions" :key="p.id" :label="p.typeName" :value="p.id" />
+					</el-select>
+					<span class="config-hint">当前归属:{{ parentLabelOfForm }}</span>
+				</el-form-item>
+				<el-form-item label="类型编码" required>
+					<el-input v-model="form.typeCode" placeholder="如 ORDER_CHANGE" maxlength="64" show-word-limit />
+				</el-form-item>
+				<el-form-item label="类型名称" required>
+					<el-input v-model="form.typeName" maxlength="128" show-word-limit />
+				</el-form-item>
+				<el-form-item label="场景编码" required>
+					<el-select v-model="form.sceneCode" placeholder="选择场景" style="width: 100%">
+						<el-option v-for="o in sceneOptions" :key="o.value" :label="o.label" :value="o.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="大屏统计类别">
+					<el-select v-model="form.monitoringCategoryKey" clearable placeholder="可选" style="width: 100%">
+						<el-option
+							v-for="o in MONITORING_CATEGORY_OPTIONS"
+							:key="o.value"
+							:label="o.label"
+							:value="o.value"
+						/>
+					</el-select>
+				</el-form-item>
+				<el-form-item label="默认严重度">
+					<el-select v-model="form.severityDefault" style="width: 100%">
+						<el-option v-for="o in SEVERITY_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="SLA(分钟)">
+					<el-input-number v-model="form.slaMinutes" :min="0" :max="100000" :step="60" />
+				</el-form-item>
+				<el-form-item label="责任角色">
+					<el-select v-model="form.ownerRoleCode" clearable placeholder="可选" style="width: 100%">
+						<el-option v-for="o in roleOptions" :key="o.value" :label="o.label" :value="o.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="升级角色">
+					<el-select v-model="form.escalateRoleCode" clearable placeholder="可选" style="width: 100%">
+						<el-option v-for="o in roleOptions" :key="o.value" :label="o.label" :value="o.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="启用">
+					<el-switch v-model="form.enabled" />
+				</el-form-item>
+				<el-form-item label="排序">
+					<el-input-number v-model="form.sortNo" :min="0" :max="9999" />
+				</el-form-item>
+				<el-form-item label="备注">
+					<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="dialogVisible = false">取消</el-button>
+				<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
+			</template>
+		</el-dialog>
+	</AidopDemoShell>
 </template>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+	display: flex;
+	align-items: center;
+	gap: 12px;
+}
+.config-hint {
+	color: var(--el-text-color-secondary);
+	font-size: 12px;
+}
+.config-tag {
+	margin-right: 6px;
+}
+</style>

+ 42 - 2
Web/src/views/aidop/s8/config/components/InlineCreateExceptionTypeDialog.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts" name="aidopS8InlineCreateExceptionTypeDialog">
 // CONFIG-WIZARD-INLINE-CREATE-MVP-1:在统一配置器内 inline 新增异常类型。
 // 复用 POST /api/aidop/s8/config/exception-types,sceneCode 强制等于当前 stageCode,enabled=true。
-import { computed, reactive, ref, watch } from 'vue';
+import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { ElMessage } from 'element-plus';
 import { s8ConfigApi, type S8ExceptionTypeConfigPayload, type S8ExceptionTypeConfigRow } from '../../api/s8ConfigApi';
 
@@ -13,6 +13,27 @@ const props = defineProps<{
 	stageLabel?: string;
 }>();
 
+// TASK-015-TREE-DEV-1:Wizard 内联新建需指定父节点,否则新建后的子类型落入"未归类"。
+// 拉取一次 tree 接口拿到 5 个真实父节点;不暴露虚拟根。
+interface ParentOption { id: number; typeCode: string; typeName: string }
+const parentOptions = ref<ParentOption[]>([]);
+async function loadParents() {
+	try {
+		const tree = await s8ConfigApi.exceptionTypes.tree({
+			tenantId: props.tenantId,
+			factoryId: props.factoryId,
+			enabledOnly: true,
+		});
+		parentOptions.value = (tree.roots ?? []).map((r) => ({
+			id: Number(r.id),
+			typeCode: r.typeCode,
+			typeName: r.typeName,
+		}));
+	} catch {
+		parentOptions.value = [];
+	}
+}
+
 const emit = defineEmits<{
 	(e: 'update:modelValue', value: boolean): void;
 	(e: 'created', payload: { typeCode: string; typeName: string; severityDefault: string }): void;
@@ -34,6 +55,7 @@ const form = reactive({
 	severityDefault: 'FOLLOW',
 	slaMinutes: 1440,
 	remark: '',
+	parentId: null as number | null,
 });
 
 const saving = ref(false);
@@ -55,15 +77,24 @@ function resetForm() {
 	form.severityDefault = 'FOLLOW';
 	form.slaMinutes = 1440;
 	form.remark = '';
+	form.parentId = null;
 }
 
 watch(visible, (v) => {
-	if (v) resetForm();
+	if (v) {
+		resetForm();
+		void loadParents();
+	}
+});
+
+onMounted(() => {
+	if (props.modelValue) void loadParents();
 });
 
 async function handleSave() {
 	if (!props.stageCode) { ElMessage.warning('请先选择所属阶段'); return; }
 	if (!form.typeName.trim()) { ElMessage.warning('请输入异常类型名称'); return; }
+	if (form.parentId == null) { ElMessage.warning('请选择所属分组父节点'); return; }
 	const code = form.typeCode.trim().toUpperCase().replace(/\s+/g, '');
 	if (!code) { ElMessage.warning('请输入内部编码'); return; }
 
@@ -80,6 +111,9 @@ async function handleSave() {
 		enabled: true,
 		sortNo: 900,
 		remark: form.remark.trim() || null,
+		// TASK-015-TREE-DEV-1:必填父节点,避免新建即落入"未归类"。
+		parentId: form.parentId,
+		isGroup: false,
 	};
 
 	saving.value = true;
@@ -104,6 +138,12 @@ async function handleSave() {
 <template>
 	<el-dialog v-model="visible" title="新增异常类型" width="520px" :close-on-click-modal="false" destroy-on-close append-to-body>
 		<el-form label-position="top" size="small">
+			<el-form-item label="所属分组父节点" required>
+				<el-select v-model="form.parentId" placeholder="选择分组父节点" filterable style="width: 100%">
+					<el-option v-for="p in parentOptions" :key="p.id" :label="p.typeName" :value="p.id" />
+				</el-select>
+				<span class="inline-create-hint">必填;分组父节点由 SeedData 维护,新建子类型必须归属其一。</span>
+			</el-form-item>
 			<el-form-item label="异常类型名称" required>
 				<el-input v-model="form.typeName" placeholder="如 客户加单延迟" maxlength="128" show-word-limit />
 			</el-form-item>

+ 4 - 1
Web/src/views/aidop/s8/config/components/WatchRuleWizardDialog.vue

@@ -355,7 +355,10 @@ watch(
 );
 
 const filteredExceptionTypes = computed(() => {
-	const all = props.exceptionTypes.filter((t) => t.enabled);
+	// TASK-015-TREE-DEV-1:分组父节点 (isGroup=true) 与虚拟根 (__VIRTUAL_UNCLASSIFIED__) 不可作为业务异常类型。
+	const all = props.exceptionTypes.filter(
+		(t) => t.enabled && !t.isGroup && t.typeCode !== '__VIRTUAL_UNCLASSIFIED__',
+	);
 	if (!form.stageCode) return all;
 	const matched = all.filter((t) => t.sceneCode === form.stageCode);
 	return matched.length > 0 ? matched : all;

+ 69 - 3
Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue

@@ -40,6 +40,14 @@
 					<el-option label="短缺" value="SHORTAGE" />
 				</el-select>
 			</el-form-item>
+			<!-- TASK-015-TREE-DEV-1:异常大类筛选(B 档收缩口径)。
+				 当前为前端过滤,依赖异常类型树映射 typeCode → parentTypeName;
+				 strict 折叠树后置 TASK-015-P2-STRICT-TREE-GROUP-1。-->
+			<el-form-item label="异常大类">
+				<el-select v-model="query.categoryFilter" clearable placeholder="全部" style="width: 150px">
+					<el-option v-for="o in CATEGORY_FILTER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
+				</el-select>
+			</el-form-item>
 			<el-form-item label="恢复状态">
 				<el-select v-model="query.recoveredStatus" clearable placeholder="全部" style="width: 130px">
 					<el-option label="已恢复" value="RECOVERED" />
@@ -73,7 +81,7 @@
 			</el-form-item>
 		</el-form>
 
-		<el-table :data="rows" v-loading="loading" border stripe @row-click="onRowClick">
+		<el-table :data="filteredRows" 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 label="模块" width="120" show-overflow-tooltip>
@@ -100,6 +108,10 @@
 					<el-tag :type="row.timeoutFlag ? 'danger' : 'info'" size="small">{{ row.timeoutFlag ? '是' : '否' }}</el-tag>
 				</template>
 			</el-table-column>
+			<!-- TASK-015-TREE-DEV-1:异常大类列(按 exceptionTypeCode → parent typeName 解析)。 -->
+			<el-table-column label="异常大类" width="120" show-overflow-tooltip>
+				<template #default="{ row }">{{ categoryNameOfRow(row) }}</template>
+			</el-table-column>
 			<el-table-column label="规则类型" width="130" align="center">
 				<template #default="{ row }">
 					<el-tag v-if="row.ruleType" :type="ruleTypeTagType(row.ruleType)" size="small">{{ ruleTypeLabel(row.ruleType) }}</el-tag>
@@ -130,10 +142,12 @@
 </template>
 
 <script setup lang="ts" name="aidopS8ExceptionList">
-import { onActivated, onMounted, reactive, ref } from 'vue';
+import { computed, onActivated, onMounted, reactive, ref } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import { s8ExceptionApi, type S8ExceptionRow } from '../api/s8ExceptionApi';
+// TASK-015-TREE-DEV-1:异常列表需要异常类型树以解析 typeCode → 父节点 typeName(大类列 + 大类筛选)。
+import { s8ConfigApi } from '../api/s8ConfigApi';
 
 const route = useRoute();
 const router = useRouter();
@@ -182,12 +196,63 @@ const query = reactive({
 	// t3j:订单链路筛选
 	relatedObjectCode: '' as string,
 	orderFlowCode: '' as string,
+	// TASK-015-TREE-DEV-1:异常大类筛选;UNCLASSIFIED 表示未归类。
+	categoryFilter: '' as string,
 	page: 1,
 	pageSize: 20,
 	tenantId: 1,
 	factoryId: 1,
 });
 
+// TASK-015-TREE-DEV-1:5 个真实父节点 + 未归类虚拟项。
+const CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
+	{ value: 'PARENT_ORDER_REVIEW', label: '订单评审' },
+	{ value: 'PARENT_PRODUCT_DESIGN', label: '产品设计' },
+	{ value: 'PARENT_MATERIAL_PURCHASE', label: '材料采购' },
+	{ value: 'PARENT_BODY_PRODUCTION', label: '本体生产' },
+	{ value: 'PARENT_FINAL_ASSEMBLY_DELIVERY', label: '总装发货' },
+	{ value: 'UNCLASSIFIED', label: '未归类' },
+];
+
+// typeCode → 父节点 { typeCode, typeName };未归类子类型不会进入此 Map。
+const typeCodeToParent = ref<Map<string, { typeCode: string; typeName: string }>>(new Map());
+
+async function loadExceptionTypeTree() {
+	try {
+		const tree = await s8ConfigApi.exceptionTypes.tree({ tenantId: 1, factoryId: 1, enabledOnly: false });
+		const map = new Map<string, { typeCode: string; typeName: string }>();
+		for (const parent of tree.roots ?? []) {
+			for (const child of parent.children ?? []) {
+				if (child.typeCode) map.set(child.typeCode, { typeCode: parent.typeCode, typeName: parent.typeName });
+			}
+		}
+		typeCodeToParent.value = map;
+	} catch {
+		typeCodeToParent.value = new Map();
+	}
+}
+
+function categoryNameOfRow(row: S8ExceptionRow): string {
+	const code = row.exceptionTypeCode;
+	if (!code) return '未归类';
+	const parent = typeCodeToParent.value.get(code);
+	return parent ? parent.typeName : '未归类';
+}
+
+// 前端过滤:依据 categoryFilter;后端无法直接按父节点过滤时使用。strict folding 后置 P2 任务。
+const filteredRows = computed<S8ExceptionRow[]>(() => {
+	const f = query.categoryFilter;
+	if (!f) return rows.value;
+	if (f === 'UNCLASSIFIED') {
+		return rows.value.filter((r) => !r.exceptionTypeCode || !typeCodeToParent.value.has(r.exceptionTypeCode));
+	}
+	return rows.value.filter((r) => {
+		if (!r.exceptionTypeCode) return false;
+		const parent = typeCodeToParent.value.get(r.exceptionTypeCode);
+		return parent?.typeCode === f;
+	});
+});
+
 // t3j:ORDER_FLOW 协议枚举 → 中文 label;与 OrderFlowConstants 同义,仅供 UI 展示。
 const ORDER_FLOW_OPTIONS: { value: string; label: string }[] = [
 	{ value: 'ORDER_REVIEW_PLAN_CALC', label: '订单评审' },
@@ -291,6 +356,7 @@ function resetQuery() {
 	query.endTime = '';
 	query.relatedObjectCode = '';
 	query.orderFlowCode = '';
+	query.categoryFilter = '';
 	dateStart.value = null;
 	dateEnd.value = null;
 	query.page = 1;
@@ -326,7 +392,7 @@ onMounted(async () => {
 	// 把 query.beginTime/endTime 字符串还原为 Date,回填到日期选择器
 	if (query.beginTime) dateStart.value = new Date(query.beginTime.slice(0, 10));
 	if (query.endTime) dateEnd.value = new Date(query.endTime.slice(0, 10));
-	await loadFilters();
+	await Promise.all([loadFilters(), loadExceptionTypeTree()]);
 	await loadList();
 });
 

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

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

+ 10 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigExceptionTypesController.cs

@@ -19,6 +19,14 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
         [FromQuery] bool? enabledOnly = null)
         => Ok(await _svc.ListAsync(tenantId, factoryId, enabledOnly));
 
+    // TASK-015-TREE-DEV-1:2 层异常类型树(5 父节点 + 子节点 + Orphans)。
+    [HttpGet("tree")]
+    public async Task<IActionResult> GetTreeAsync(
+        [FromQuery] long tenantId = 1,
+        [FromQuery] long factoryId = 1,
+        [FromQuery] bool enabledOnly = true)
+        => Ok(await _svc.GetTreeAsync(tenantId, factoryId, enabledOnly));
+
     [HttpGet("{typeCode}")]
     public async Task<IActionResult> GetAsync(string typeCode, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
@@ -50,7 +58,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
-        await _svc.DeleteAsync(id);
-        return Ok();
+        try { await _svc.DeleteAsync(id); return Ok(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 }

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

@@ -439,6 +439,45 @@ public class AdoS8DimensionDto
     public string? Remark { get; set; }
 }
 
+// ── TASK-015-TREE-DEV-1:异常类型树形结构 DTO ─────────────────────────────
+/// <summary>
+/// 异常类型树节点;2 层结构(父节点 IsGroup=true → 子节点 IsGroup=false)。
+/// 父节点不可作为业务异常类型;未归类子节点(ParentId=null AND IsGroup=false)由前端组装虚拟根。
+/// </summary>
+public class AdoS8ExceptionTypeTreeNodeDto
+{
+    public long Id { get; set; }
+    public string TypeCode { get; set; } = string.Empty;
+    public string TypeName { get; set; } = string.Empty;
+    public long? ParentId { get; set; }
+    public bool IsGroup { get; set; }
+    public string SceneCode { get; set; } = string.Empty;
+    public string SeverityDefault { get; set; } = string.Empty;
+    public int SlaMinutes { get; set; }
+    public string? OwnerRoleCode { get; set; }
+    public string? EscalateRoleCode { get; set; }
+    public string StatsMode { get; set; } = string.Empty;
+    public string? MonitoringCategoryKey { get; set; }
+    public bool MobileVisible { get; set; }
+    public string? Icon { get; set; }
+    public bool Enabled { get; set; }
+    public int SortNo { get; set; }
+    public string? Remark { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public DateTime? UpdatedAt { get; set; }
+    public List<AdoS8ExceptionTypeTreeNodeDto> Children { get; set; } = new();
+}
+
+/// <summary>
+/// 异常类型树出参:顶层只包含 5 个真实父节点(IsGroup=true)。
+/// 未归类节点单独以 Orphans 列表返回,由前端拼装虚拟根,避免 DB 引入未归类父节点。
+/// </summary>
+public class AdoS8ExceptionTypeTreeDto
+{
+    public List<AdoS8ExceptionTypeTreeNodeDto> Roots { get; set; } = new();
+    public List<AdoS8ExceptionTypeTreeNodeDto> Orphans { get; set; } = new();
+}
+
 public class AdoS8DimensionNodeDto
 {
     public long Id { get; set; }

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

@@ -65,6 +65,19 @@ public class AdoS8ExceptionType
     [SugarColumn(ColumnName = "sort_no")]
     public int SortNo { get; set; }
 
+    /// <summary>
+    /// TASK-015-TREE-DEV-1:父节点 Id(指向同表 IsGroup=true 的父节点)。null 表示顶层(5 个真实父节点或未归类子节点)。
+    /// 不引入物理 FK;严格 2 层树。
+    /// </summary>
+    [SugarColumn(ColumnName = "parent_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ParentId { get; set; }
+
+    /// <summary>
+    /// TASK-015-TREE-DEV-1:是否为分组父节点。true 仅作为分组容器,禁止被 Wizard / 规则配置 / 业务建单选为业务异常类型。
+    /// </summary>
+    [SugarColumn(ColumnName = "is_group", ColumnDataType = "boolean")]
+    public bool IsGroup { get; set; } = false;
+
     [SugarColumn(ColumnName = "remark", Length = 500, IsNullable = true)]
     public string? Remark { get; set; }
 

+ 57 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8ExceptionTypeSeedData.cs

@@ -29,13 +29,39 @@ namespace Admin.NET.Plugin.AiDOP;
 [IncreSeed]
 public class S8ExceptionTypeSeedData : ISqlSugarEntitySeedData<Entity.S8.AdoS8ExceptionType>
 {
+    private const long BaseId = 1329908100000L;
+    // TASK-015-TREE-DEV-1:5 个真实父节点 Id 偏移(baseId + 100..104)。
+    // 选取范围避开现有子节点 Id(baseId+1..44);父节点 Id 也作为子节点 ParentId 取值。
+    private const long ParentIdOrderReview = BaseId + 100;
+    private const long ParentIdProductDesign = BaseId + 101;
+    private const long ParentIdMaterialPurchase = BaseId + 102;
+    private const long ParentIdBodyProduction = BaseId + 103;
+    private const long ParentIdFinalAssemblyDelivery = BaseId + 104;
+
+    private static long? ParentIdForCategory(string? key) => key switch
+    {
+        S8MonitoringCategory.OrderReview => ParentIdOrderReview,
+        S8MonitoringCategory.ProductDesign => ParentIdProductDesign,
+        S8MonitoringCategory.MaterialPurchase => ParentIdMaterialPurchase,
+        S8MonitoringCategory.BodyProduction => ParentIdBodyProduction,
+        S8MonitoringCategory.FinalAssemblyDelivery => ParentIdFinalAssemblyDelivery,
+        _ => null,
+    };
+
     public IEnumerable<Entity.S8.AdoS8ExceptionType> HasData()
     {
         var ct = DateTime.Parse("2026-04-18 00:00:00");
-        const long baseId = 1329908100000L;
+        const long baseId = BaseId;
 
         return new[]
         {
+            // ── TASK-015-TREE-DEV-1:5 个真实分组父节点(IsGroup=true / ParentId=null / SceneCode=ALL)──
+            P(ParentIdOrderReview,            "PARENT_ORDER_REVIEW",            "订单评审", S8MonitoringCategory.OrderReview,            10, ct),
+            P(ParentIdProductDesign,          "PARENT_PRODUCT_DESIGN",          "产品设计", S8MonitoringCategory.ProductDesign,          20, ct),
+            P(ParentIdMaterialPurchase,       "PARENT_MATERIAL_PURCHASE",       "材料采购", S8MonitoringCategory.MaterialPurchase,       30, ct),
+            P(ParentIdBodyProduction,         "PARENT_BODY_PRODUCTION",         "本体生产", S8MonitoringCategory.BodyProduction,         40, ct),
+            P(ParentIdFinalAssemblyDelivery,  "PARENT_FINAL_ASSEMBLY_DELIVERY", "总装发货", S8MonitoringCategory.FinalAssemblyDelivery,  50, ct),
+
             // ── S1 产销协同 + S7 成品仓储(交付侧) ──
             T(baseId + 1,  "ORDER_CHANGE",             "订单变更",           S8SceneCode.S1, 60,  "ROLE_ORDER_PLANNER",      "FOLLOW", 100, ct, monitoringCategoryKey: S8MonitoringCategory.OrderReview),
             T(baseId + 2,  "DELIVERY_DELAY",           "交期延迟",           S8SceneCode.S7, 120, "ROLE_ORDER_PLANNER",      "SERIOUS",   101, ct, monitoringCategoryKey: S8MonitoringCategory.FinalAssemblyDelivery),
@@ -129,6 +155,36 @@ public class S8ExceptionTypeSeedData : ISqlSugarEntitySeedData<Entity.S8.AdoS8Ex
             SortNo = sortNo,
             Remark = remark,
             MonitoringCategoryKey = monitoringCategoryKey,
+            // TASK-015-TREE-DEV-1:ParentId 由 monitoringCategoryKey 派生;空值视为未归类(前端虚拟根承载)。
+            ParentId = ParentIdForCategory(monitoringCategoryKey),
+            IsGroup = false,
+            CreatedAt = ct,
+        };
+
+    // TASK-015-TREE-DEV-1:分组父节点工厂。SceneCode=ALL 仅用作 placeholder;不参与业务建单。
+    private static Entity.S8.AdoS8ExceptionType P(
+        long id, string typeCode, string typeName, string monitoringCategoryKey, int sortNo, DateTime ct) =>
+        new()
+        {
+            Id = id,
+            TenantId = 0,
+            FactoryId = 0,
+            TypeCode = typeCode,
+            TypeName = typeName,
+            SceneCode = "ALL",
+            SeverityDefault = "FOLLOW",
+            SlaMinutes = 0,
+            OwnerRoleCode = null,
+            EscalateRoleCode = null,
+            StatsMode = "ALL",
+            MobileVisible = false,
+            Icon = null,
+            Enabled = true,
+            SortNo = sortNo,
+            Remark = "[TASK-015-TREE-DEV-1] 分组父节点;仅作为树形容器,不可作为业务异常类型。",
+            MonitoringCategoryKey = monitoringCategoryKey,
+            ParentId = null,
+            IsGroup = true,
             CreatedAt = ct,
         };
 }

+ 97 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionTypeService.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
@@ -5,6 +6,8 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 /// <summary>
 /// 异常类型配置服务(ado_s8_exception_type)。
 /// 读取策略:工厂覆盖优先 → 全局基线(tenant_id=0 / factory_id=0)兜底。
+/// TASK-015-TREE-DEV-1:新增 2 层树形结构(父节点 IsGroup=true / 子节点 IsGroup=false);
+/// 父节点不可被业务建单 / Wizard / 规则配置选用;未归类子节点 ParentId=null 由前端虚拟根承载。
 /// </summary>
 public class S8ExceptionTypeService : ITransient
 {
@@ -41,14 +44,50 @@ public class S8ExceptionTypeService : ITransient
         return rows.OrderByDescending(x => x.FactoryId).FirstOrDefault();
     }
 
+    /// <summary>
+    /// TASK-015-TREE-DEV-1:返回 2 层树(顶层 5 个真实父节点 + 各自子节点) + Orphans(ParentId=null AND IsGroup=false)。
+    /// 父节点在 DB 中以 IsGroup=true 标记,禁止虚拟未归类父节点持久化。
+    /// </summary>
+    public async Task<AdoS8ExceptionTypeTreeDto> GetTreeAsync(long tenantId, long factoryId, bool enabledOnly = true)
+    {
+        var rows = await ListAsync(tenantId, factoryId, enabledOnly ? true : (bool?)null);
+        var nodes = rows.Select(ToTreeNode).ToList();
+
+        // 父节点:IsGroup=true 且 ParentId=null。允许 0 父节点(首次 deploy 未播种时返回空树)。
+        var roots = nodes.Where(n => n.IsGroup && n.ParentId == null)
+            .OrderBy(n => n.SortNo).ThenBy(n => n.TypeCode).ToList();
+        var rootIds = new HashSet<long>(roots.Select(r => r.Id));
+
+        // 子节点:IsGroup=false 且 ParentId 命中某父节点 Id。
+        var children = nodes.Where(n => !n.IsGroup && n.ParentId.HasValue && rootIds.Contains(n.ParentId!.Value)).ToList();
+        foreach (var root in roots)
+        {
+            root.Children = children.Where(c => c.ParentId == root.Id)
+                .OrderBy(c => c.SortNo).ThenBy(c => c.TypeCode).ToList();
+        }
+
+        // 未归类:IsGroup=false 且(ParentId=null OR ParentId 不命中任一父节点)。
+        var orphans = nodes.Where(n => !n.IsGroup && (n.ParentId == null || !rootIds.Contains(n.ParentId!.Value)))
+            .OrderBy(n => n.SortNo).ThenBy(n => n.TypeCode).ToList();
+
+        return new AdoS8ExceptionTypeTreeDto { Roots = roots, Orphans = orphans };
+    }
+
     public async Task<AdoS8ExceptionType> CreateAsync(AdoS8ExceptionType body)
     {
         if (string.IsNullOrWhiteSpace(body.TypeCode) || string.IsNullOrWhiteSpace(body.TypeName))
             throw new S8BizException("类型编码和名称必填");
+        // TASK-015-TREE-DEV-1:禁止通过普通 CRUD 创建父节点;父节点仅由 Seed 维护。
+        if (body.IsGroup)
+            throw new S8BizException("不允许通过此接口创建分组父节点");
         var exists = await _rep.AsQueryable()
             .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.TypeCode == body.TypeCode);
         if (exists) throw new S8BizException("同一工厂下类型编码已存在");
 
+        // TASK-015-TREE-DEV-1:parentId 非空时必须指向真实父节点(IsGroup=true)。
+        await ValidateParentIdAsync(body.ParentId);
+
+        body.IsGroup = false;
         body.Id = 0;
         body.CreatedAt = DateTime.Now;
         body.UpdatedAt = null;
@@ -65,20 +104,77 @@ public class S8ExceptionTypeService : ITransient
             .AnyAsync(x => x.Id != id && x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.TypeCode == body.TypeCode);
         if (dup) throw new S8BizException("同一工厂下类型编码已存在");
 
+        // TASK-015-TREE-DEV-1:不允许把子节点改成父节点,也不允许把父节点改成子节点(IsGroup 强制保持原值)。
+        if (body.IsGroup != e.IsGroup)
+            throw new S8BizException("不允许切换分组属性 (IsGroup)");
+
+        if (!e.IsGroup)
+        {
+            // 子节点 parentId 可调整,但必须指向真实父节点。
+            await ValidateParentIdAsync(body.ParentId);
+        }
+        else
+        {
+            // 父节点 parentId 必须保持 null。
+            body.ParentId = null;
+        }
+
         body.Id = id;
+        body.IsGroup = e.IsGroup;
         body.CreatedAt = e.CreatedAt;
         body.UpdatedAt = DateTime.Now;
         await _rep.UpdateAsync(body);
         return body;
     }
 
+    private async Task ValidateParentIdAsync(long? parentId)
+    {
+        if (parentId == null) return;
+        var parent = await _rep.GetByIdAsync(parentId.Value);
+        if (parent == null) throw new S8BizException("父节点不存在");
+        if (!parent.IsGroup) throw new S8BizException("父节点必须为分组父节点 (IsGroup=true)");
+    }
+
     public async Task SetEnabledAsync(long id, bool enabled)
     {
         var e = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        // TASK-015-TREE-DEV-1:父节点的 Enabled 由 Seed 维护,禁止手工切换(避免父子启用状态错位)。
+        if (e.IsGroup) throw new S8BizException("分组父节点不允许切换启用状态");
         e.Enabled = enabled;
         e.UpdatedAt = DateTime.Now;
         await _rep.UpdateAsync(e);
     }
 
-    public async Task DeleteAsync(long id) => await _rep.DeleteByIdAsync(id);
+    public async Task DeleteAsync(long id)
+    {
+        // TASK-015-TREE-DEV-1:父节点禁止删除,避免子节点变成"悬挂未归类"。
+        var e = await _rep.GetByIdAsync(id);
+        if (e == null) return;
+        if (e.IsGroup) throw new S8BizException("分组父节点不允许删除");
+        await _rep.DeleteByIdAsync(id);
+    }
+
+    private static AdoS8ExceptionTypeTreeNodeDto ToTreeNode(AdoS8ExceptionType e) => new()
+    {
+        Id = e.Id,
+        TypeCode = e.TypeCode,
+        TypeName = e.TypeName,
+        ParentId = e.ParentId,
+        IsGroup = e.IsGroup,
+        SceneCode = e.SceneCode,
+        SeverityDefault = e.SeverityDefault,
+        SlaMinutes = e.SlaMinutes,
+        OwnerRoleCode = e.OwnerRoleCode,
+        EscalateRoleCode = e.EscalateRoleCode,
+        StatsMode = e.StatsMode,
+        MonitoringCategoryKey = e.MonitoringCategoryKey,
+        MobileVisible = e.MobileVisible,
+        Icon = e.Icon,
+        Enabled = e.Enabled,
+        SortNo = e.SortNo,
+        Remark = e.Remark,
+        CreatedAt = e.CreatedAt,
+        UpdatedAt = e.UpdatedAt,
+        Children = new List<AdoS8ExceptionTypeTreeNodeDto>(),
+    };
 }