Browse Source

feat(s8): wire config wizard drafts frontend

YY968XX 2 tháng trước cách đây
mục cha
commit
1b9d88c5e2

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

@@ -245,4 +245,93 @@ export const s8ConfigApi = {
 		roleOptions: () =>
 			service.get<Array<{ value: string; label: string }>>('/api/aidop/s8/config/notification-layers/role-options').then(unwrap),
 	},
+	wizardDrafts: {
+		list: (params: S8WizardDraftListQuery) =>
+			service.get<S8WizardDraftListResult>('/api/aidop/s8/config/wizard-drafts', { params }).then(unwrap),
+		detail: (id: number | string) =>
+			service.get<S8WizardDraftDetail>(`/api/aidop/s8/config/wizard-drafts/${id}`).then(unwrap),
+		create: (body: S8WizardDraftCreatePayload) =>
+			service.post<S8WizardDraftDetail>('/api/aidop/s8/config/wizard-drafts', body).then(unwrap),
+		update: (id: number | string, body: S8WizardDraftUpdatePayload) =>
+			service.put<S8WizardDraftDetail>(`/api/aidop/s8/config/wizard-drafts/${id}`, body).then(unwrap),
+		remove: (id: number | string) =>
+			service.delete(`/api/aidop/s8/config/wizard-drafts/${id}`).then(unwrap),
+		generateRule: (id: number | string, body: S8WizardDraftGenerateRulePayload) =>
+			service.post<S8WizardDraftGenerateRuleResult>(`/api/aidop/s8/config/wizard-drafts/${id}/generate-rule`, body).then(unwrap),
+	},
 };
+
+// CONFIG-WIZARD-DRAFT-FRONTEND-1:S8 统一配置器草稿。
+export type S8WizardDraftStatus = 'IN_PROGRESS' | 'GENERATED' | string;
+
+export interface S8WizardDraftCreatePayload {
+	tenantId: number;
+	factoryId: number;
+	draftCode: string;
+	draftName?: string | null;
+	wizardJson: string;
+	currentStep?: number;
+	mechanism?: string | null;
+	stageCode?: string | null;
+	orderFlowCode?: string | null;
+	exceptionTypeCode?: string | null;
+	remark?: string | null;
+}
+
+export interface S8WizardDraftUpdatePayload {
+	draftName?: string | null;
+	wizardJson?: string | null;
+	currentStep?: number;
+	mechanism?: string | null;
+	stageCode?: string | null;
+	orderFlowCode?: string | null;
+	exceptionTypeCode?: string | null;
+	remark?: string | null;
+}
+
+export interface S8WizardDraftListQuery {
+	tenantId: number;
+	factoryId: number;
+	status?: S8WizardDraftStatus | '';
+	page?: number;
+	pageSize?: number;
+}
+
+export interface S8WizardDraftListItem {
+	id: number | string;
+	tenantId: number | string;
+	factoryId: number | string;
+	draftCode: string;
+	draftName?: string | null;
+	status: S8WizardDraftStatus;
+	currentStep: number;
+	mechanism?: string | null;
+	stageCode?: string | null;
+	orderFlowCode?: string | null;
+	exceptionTypeCode?: string | null;
+	generatedRuleId?: number | string | null;
+	remark?: string | null;
+	createdAt: string;
+	updatedAt?: string | null;
+}
+
+export interface S8WizardDraftListResult {
+	items: S8WizardDraftListItem[];
+	total: number;
+	page: number;
+	pageSize: number;
+}
+
+export interface S8WizardDraftDetail extends S8WizardDraftListItem {
+	wizardJson: string;
+}
+
+export interface S8WizardDraftGenerateRulePayload {
+	rulePayload: S8WatchRuleCreatePayload;
+}
+
+export interface S8WizardDraftGenerateRuleResult {
+	ruleId: number | string;
+	ruleCode: string;
+	draftStatus: S8WizardDraftStatus;
+}

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

@@ -3,12 +3,14 @@ import { computed, onActivated, onDeactivated, onMounted, onUnmounted, reactive,
 import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import WatchRuleWizardDialog from './components/WatchRuleWizardDialog.vue';
+import WizardDraftDrawer from './components/WizardDraftDrawer.vue';
 import {
 	s8ConfigApi,
 	type S8DimensionNode,
 	type S8ExceptionTypeConfigRow,
 	type S8WatchRuleConfigRow,
 	type S8WatchRuleType,
+	type S8WizardDraftDetail,
 } from '../api/s8ConfigApi';
 
 const TENANT_ID = 1;
@@ -1028,12 +1030,60 @@ async function loadDataSources() {
 	}
 }
 function openWizard() {
+	editingDraftDetail.value = null;
 	wizardVisible.value = true;
 }
 async function onWizardSaved() {
 	await loadRows();
 	// CONFIG-RULE-CENTER-SHELL-MVP-1:保存新规则后回到第 1 页,让用户看到新建结果。
 	currentPage.value = 1;
+	await loadDraftCount();
+	if (draftDrawerVisible.value) draftDrawerRef.value?.loadList?.();
+}
+
+// CONFIG-WIZARD-DRAFT-FRONTEND-1:草稿配置接入。
+const draftDrawerVisible = ref(false);
+const draftDrawerRef = ref<{ loadList?: () => void } | null>(null);
+const draftCount = ref(0);
+const editingDraftDetail = ref<S8WizardDraftDetail | null>(null);
+
+async function loadDraftCount() {
+	try {
+		const res = await s8ConfigApi.wizardDrafts.list({
+			tenantId: TENANT_ID,
+			factoryId: FACTORY_ID,
+			page: 1,
+			pageSize: 1,
+		});
+		draftCount.value = Number(res?.total ?? 0);
+	} catch {
+		// 不阻塞主台账加载
+	}
+}
+
+function openDraftDrawer() {
+	draftDrawerVisible.value = true;
+}
+
+async function handleEditDraft(draftId: number | string) {
+	try {
+		const detail = await s8ConfigApi.wizardDrafts.detail(draftId);
+		editingDraftDetail.value = detail;
+		draftDrawerVisible.value = false;
+		wizardVisible.value = true;
+	} catch (e: any) {
+		const msg = e?.response?.data?.message ?? e?.message ?? '加载草稿失败';
+		ElMessage.error(msg);
+	}
+}
+
+function handleDraftSaved() {
+	loadDraftCount();
+	if (draftDrawerVisible.value) draftDrawerRef.value?.loadList?.();
+}
+
+function handleDraftDeleted() {
+	loadDraftCount();
 }
 
 onMounted(() => {
@@ -1041,6 +1091,7 @@ onMounted(() => {
 	loadExceptionTypes();
 	loadDimensionNodes();
 	loadDataSources();
+	loadDraftCount();
 	startAutoRefresh();
 });
 onUnmounted(() => stopAutoRefresh());
@@ -1053,6 +1104,7 @@ onDeactivated(() => stopAutoRefresh());
 		<!-- CONFIG-RULE-CENTER-SHELL-MVP-1:主操作 + 统计 chip + 业务化筛选 + 前端分页 -->
 		<div class="rule-toolbar">
 			<el-button size="small" type="primary" @click="openWizard">新建监控配置</el-button>
+			<el-button size="small" @click="openDraftDrawer">草稿配置 ({{ draftCount }})</el-button>
 			<el-button size="small" @click="loadRows" :loading="loading">手动刷新</el-button>
 			<el-checkbox v-model="showAdvancedColumns" size="small" style="margin-left: 8px">显示运行态</el-checkbox>
 			<span class="rule-toolbar__hint">每 30 秒自动刷新;编辑抽屉打开期间暂停刷新</span>
@@ -1092,7 +1144,21 @@ onDeactivated(() => stopAutoRefresh());
 			:data-sources="dataSources"
 			:tenant-id="TENANT_ID"
 			:factory-id="FACTORY_ID"
+			:draft-data="editingDraftDetail"
 			@saved="onWizardSaved"
+			@draft-saved="handleDraftSaved"
+			@generated="onWizardSaved"
+		/>
+
+		<WizardDraftDrawer
+			ref="draftDrawerRef"
+			v-model="draftDrawerVisible"
+			:tenant-id="TENANT_ID"
+			:factory-id="FACTORY_ID"
+			:stage-nodes="stageNodes"
+			:exception-types="exceptionTypes"
+			@edit-draft="handleEditDraft"
+			@deleted="handleDraftDeleted"
 		/>
 
 		<!-- CONFIG-RULE-CENTER-SHELL-MVP-1:业务台账(默认隐藏运行态列) -->

+ 288 - 38
Web/src/views/aidop/s8/config/components/WatchRuleWizardDialog.vue

@@ -1,7 +1,8 @@
 <script setup lang="ts" name="aidopS8WatchRuleWizardDialog">
-// CONFIG-RULE-WIZARD-BIZ-CONDITION-MVP-1:业务条件向导(5 步)
-// 1) 报警机制 2) 业务维度 3) 监控对象/指标 4) 判定标准 5) 基础配置 + 保存
-// MANUAL_REPORT 不创建 watch_rule,跳转主动提报页。
+// CONFIG-WIZARD-DRAFT-FRONTEND-1:5 步业务条件向导 + 后端草稿接入。
+// 1) 报警机制 2) 业务维度 3) 监控对象/指标 4) 判定标准 5) 基础配置
+// 非 MANUAL_REPORT:先 saveDraft 落库,再 generate-rule 生成正式 watch_rule。
+// MANUAL_REPORT:跳转主动提报,不写草稿、不写规则。
 import { computed, reactive, ref, watch } from 'vue';
 import { useRouter } from 'vue-router';
 import { ElMessage } from 'element-plus';
@@ -10,6 +11,7 @@ import {
 	type S8DimensionNode,
 	type S8ExceptionTypeConfigRow,
 	type S8WatchRuleCreatePayload,
+	type S8WizardDraftDetail,
 } from '../../api/s8ConfigApi';
 
 interface DataSourceLike {
@@ -27,11 +29,14 @@ const props = defineProps<{
 	dataSources: DataSourceLike[];
 	tenantId?: number;
 	factoryId?: number;
+	draftData?: S8WizardDraftDetail | null;
 }>();
 
 const emit = defineEmits<{
 	(e: 'update:modelValue', value: boolean): void;
 	(e: 'saved'): void;
+	(e: 'draftSaved'): void;
+	(e: 'generated'): void;
 }>();
 
 const router = useRouter();
@@ -43,6 +48,7 @@ const visible = computed({
 
 const step = ref(0);
 const saving = ref(false);
+const savingDraft = ref(false);
 
 const MECHANISM_OPTIONS = [
 	{ value: 'DATE', label: '日期类', desc: '适用于交期、到期、超期、SLA 等时间判断' },
@@ -56,7 +62,6 @@ const SEVERITY_OPTIONS = [
 	{ value: 'SERIOUS', label: '严重' },
 ];
 
-// CONFIG-RULE-WIZARD-BIZ-CONDITION-MVP-1:业务对象 + 监控指标常量字典(前端 MVP,不依赖后端字典)
 interface BizMetric {
 	metricCode: string;
 	metricLabel: string;
@@ -131,11 +136,10 @@ const form = reactive({
 	mechanism: '' as '' | 'DATE' | 'VALUE_RANGE' | 'RATIO' | 'MANUAL_REPORT',
 	stageCode: '',
 	orderFlowCode: '',
-	objectIndex: -1, // BUSINESS_MONITOR_OPTIONS index
+	objectIndex: -1,
 	metricCode: '',
 	exceptionTypeCode: '',
 	severity: 'FOLLOW',
-	// 判定标准
 	graceMinutes: 0,
 	completedStates: 'CLOSED,COMPLETED,DONE',
 	lowerBound: null as number | null,
@@ -144,16 +148,50 @@ const form = reactive({
 	toleranceAbs: 0,
 	toleranceRatioPct: 0,
 	targetRatio: 95,
-	// 基础
 	ruleCode: '',
 	pollIntervalSeconds: 300,
 	triggerCountRequired: 1,
 	recoverCountRequired: 1,
 });
 
+// CONFIG-WIZARD-DRAFT-FRONTEND-1:草稿身份 + 用户可改的草稿名。
+const currentDraftId = ref<number | string | null>(null);
+const currentDraftCode = ref<string>('');
+const draftName = ref<string>('');
+// 字典演进兜底:草稿回填后若按 objectType+objectLabel 找不到字典项,标记禁用 generate。
+const dictionaryMissing = ref(false);
+const fallbackObjectLabel = ref('');
+const fallbackMetricLabel = ref('');
+
+function pad2(n: number) { return n < 10 ? `0${n}` : `${n}`; }
+function nowTimestampCompact() {
+	const d = new Date();
+	return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
+}
+function nowMmDdHhMm() {
+	const d = new Date();
+	return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+}
+function generateDraftCode(): string {
+	const rand = Math.random().toString(36).slice(-3).toUpperCase();
+	return `DRAFT-S8-${nowTimestampCompact()}-${rand}`;
+}
+function mechanismChinese(m: string): string {
+	return MECHANISM_OPTIONS.find((o) => o.value === m)?.label ?? '';
+}
+function generateDraftName(): string {
+	const mechLabel = mechanismChinese(form.mechanism);
+	const stageLabel = props.stageNodes.find((n) => n.nodeCode === form.stageCode)?.nodeName ?? '';
+	const metric = selectedMetric.value?.metricLabel ?? '';
+	if (mechLabel && stageLabel && metric) return `${mechLabel}·${stageLabel}·${metric}`;
+	if (mechLabel) return `${mechLabel}·S8 配置草稿`;
+	return `S8 配置草稿 ${nowMmDdHhMm()}`;
+}
+
 function resetForm() {
 	step.value = 0;
 	saving.value = false;
+	savingDraft.value = false;
 	form.mechanism = '';
 	form.stageCode = '';
 	form.orderFlowCode = '';
@@ -173,13 +211,25 @@ function resetForm() {
 	form.pollIntervalSeconds = 300;
 	form.triggerCountRequired = 1;
 	form.recoverCountRequired = 1;
+	currentDraftId.value = null;
+	currentDraftCode.value = generateDraftCode();
+	draftName.value = '';
+	dictionaryMissing.value = false;
+	fallbackObjectLabel.value = '';
+	fallbackMetricLabel.value = '';
 }
 
-watch(visible, (v) => { if (v) resetForm(); });
+watch(visible, (v) => {
+	if (!v) return;
+	if (props.draftData) {
+		hydrateFromDraft(props.draftData);
+	} else {
+		resetForm();
+	}
+});
 
 const isManualReport = computed(() => form.mechanism === 'MANUAL_REPORT');
 
-// 当前机制下可选业务对象
 const filteredObjects = computed(() => {
 	if (!form.mechanism || isManualReport.value) return [];
 	return BUSINESS_MONITOR_OPTIONS.filter((o) => o.mechanisms.includes(form.mechanism as any));
@@ -203,13 +253,14 @@ watch(selectedMetric, (m) => {
 	if (m) form.unit = m.unit;
 });
 
-// 当机制切换时,重置后续选择
 watch(() => form.mechanism, () => {
 	form.objectIndex = -1;
 	form.metricCode = '';
+	dictionaryMissing.value = false;
 });
 watch(() => form.objectIndex, () => {
 	form.metricCode = '';
+	if (form.objectIndex >= 0) dictionaryMissing.value = false;
 });
 
 const filteredExceptionTypes = computed(() => {
@@ -223,7 +274,6 @@ const exceptionTypeRow = computed(() =>
 	props.exceptionTypes.find((t) => t.typeCode === form.exceptionTypeCode) ?? null,
 );
 
-// thresholdDisplay 推导
 const thresholdDisplay = computed(() => {
 	const u = form.unit || '';
 	if (form.mechanism === 'DATE') {
@@ -241,7 +291,6 @@ const thresholdDisplay = computed(() => {
 	return '';
 });
 
-// 显示 label
 const mechanismLabel = computed(() => MECHANISM_OPTIONS.find((o) => o.value === form.mechanism)?.label ?? '—');
 const stageNodeLabel = computed(() => props.stageNodes.find((n) => n.nodeCode === form.stageCode)?.nodeName ?? '—');
 const orderFlowNodeLabel = computed(() => {
@@ -260,6 +309,13 @@ const ruleCodePlaceholder = computed(() => {
 	}
 });
 
+const dictionaryHint = computed(() => {
+	if (!dictionaryMissing.value) return '';
+	const obj = fallbackObjectLabel.value || '原对象';
+	const metric = fallbackMetricLabel.value || '原指标';
+	return `当前指标字典已变化(原:${obj}·${metric}),请重新选择监控对象和指标后再生成正式规则。`;
+});
+
 function pickMechanism(value: string) {
 	form.mechanism = value as typeof form.mechanism;
 }
@@ -277,7 +333,6 @@ function nextStep() {
 		if (!form.metricCode) { ElMessage.warning('请选择监控指标'); return; }
 		step.value = 3;
 	} else if (step.value === 3) {
-		// 判定标准校验
 		if (form.mechanism === 'DATE') {
 			const states = form.completedStates.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
 			if (states.length === 0) { ElMessage.warning('完成状态至少填一个'); return; }
@@ -291,6 +346,8 @@ function nextStep() {
 		if (!form.exceptionTypeCode) { ElMessage.warning('请选择异常类型'); return; }
 		const def = exceptionTypeRow.value?.severityDefault;
 		if (def) form.severity = def;
+		// 进入第 5 步前,若 draftName 为空,自动生成默认值;用户仍可在 step 4 编辑。
+		if (!draftName.value.trim()) draftName.value = generateDraftName();
 		step.value = 4;
 	}
 }
@@ -298,7 +355,7 @@ function prevStep() { if (step.value > 0) step.value -= 1; }
 
 function ruleTypeFromMechanism(mech: string): string {
 	if (mech === 'DATE') return 'TIMEOUT';
-	return 'OUT_OF_RANGE'; // VALUE_RANGE / RATIO 共用 OOR evaluator
+	return 'OUT_OF_RANGE';
 }
 
 function buildParamsJson(): string {
@@ -341,7 +398,6 @@ function buildParamsJson(): string {
 		return JSON.stringify(params);
 	}
 	if (form.mechanism === 'RATIO') {
-		// RATIO 只写固定 lowerBound(目标比例),禁止写 lowerBoundField/upperBoundField
 		return JSON.stringify({
 			...common,
 			measuredValueField: m?.measuredValueField ?? 'measured_value',
@@ -357,7 +413,6 @@ function buildExpression(): string {
 	if (form.mechanism === 'DATE') {
 		return "SELECT 'DEMO-OBJECT-001' AS related_object_code, 'DEMO-OBJECT-001' AS source_object_id, 'DEMO-OBJECT-001' AS related_object_name, DATE_SUB(NOW(), INTERVAL 1 HOUR) AS due_at, 'PENDING' AS status WHERE 1=0";
 	}
-	// VALUE_RANGE / RATIO
 	return "SELECT 'DEMO-OBJECT-001' AS related_object_code, 'DEMO-OBJECT-001' AS source_object_id, 'DEMO-OBJECT-001' AS related_object_name, 0 AS measured_value WHERE 1=0";
 }
 
@@ -367,20 +422,174 @@ function pickDataSourceId(): number | null {
 	return (enabledOne ?? props.dataSources[0]).id;
 }
 
-async function save() {
-	if (!form.ruleCode.trim()) { ElMessage.warning('规则编码不能为空'); return; }
-	if (!form.stageCode) { ElMessage.warning('阶段维度未选择'); return; }
-	if (!form.exceptionTypeCode) { ElMessage.warning('异常类型未选择'); return; }
-	if (form.objectIndex < 0 || !form.metricCode) { ElMessage.warning('监控对象或监控指标未选择'); return; }
-	if (form.pollIntervalSeconds <= 0) { ElMessage.warning('轮询间隔必须大于 0'); return; }
+// CONFIG-WIZARD-DRAFT-FRONTEND-1:序列化 / 反序列化向导态。
+function serializeWizardState(): string {
+	const obj = selectedObject.value;
+	const m = selectedMetric.value;
+	return JSON.stringify({
+		version: 1,
+		step: step.value,
+		form: {
+			mechanism: form.mechanism,
+			stageCode: form.stageCode,
+			orderFlowCode: form.orderFlowCode,
+			exceptionTypeCode: form.exceptionTypeCode,
+			severity: form.severity,
+			objectType: obj?.objectType ?? '',
+			objectLabel: obj?.objectLabel ?? '',
+			metricCode: form.metricCode,
+			metricLabel: m?.metricLabel ?? '',
+			unit: form.unit,
+			graceMinutes: form.graceMinutes,
+			completedStates: form.completedStates,
+			lowerBound: form.lowerBound,
+			upperBound: form.upperBound,
+			toleranceAbs: form.toleranceAbs,
+			toleranceRatioPct: form.toleranceRatioPct,
+			targetRatio: form.targetRatio,
+			ruleCode: form.ruleCode,
+			pollIntervalSeconds: form.pollIntervalSeconds,
+			triggerCountRequired: form.triggerCountRequired,
+			recoverCountRequired: form.recoverCountRequired,
+		},
+		labels: {
+			mechanismLabel: mechanismLabel.value,
+			stageLabel: stageNodeLabel.value,
+			objectLabel: obj?.objectLabel ?? '',
+			metricLabel: m?.metricLabel ?? '',
+			thresholdDisplay: thresholdDisplay.value,
+		},
+	});
+}
+
+function hydrateFromDraft(draft: S8WizardDraftDetail) {
+	resetForm();
+	currentDraftId.value = draft.id;
+	currentDraftCode.value = draft.draftCode;
+	draftName.value = draft.draftName ?? '';
+	let parsed: any = null;
+	try { parsed = JSON.parse(draft.wizardJson); } catch { parsed = null; }
+	if (!parsed || typeof parsed !== 'object') {
+		ElMessage.warning('草稿内容损坏,已重置');
+		return;
+	}
+	if (parsed.version && parsed.version !== 1) {
+		ElMessage.warning(`草稿版本 ${parsed.version} 不被当前前端支持,请新建`);
+		return;
+	}
+	const f = parsed.form ?? {};
+	const labels = parsed.labels ?? {};
+
+	// 1) 先恢复 mechanism(filteredObjects 依赖它)
+	if (f.mechanism) form.mechanism = f.mechanism;
+	if (f.stageCode) form.stageCode = f.stageCode;
+	if (f.orderFlowCode) form.orderFlowCode = f.orderFlowCode;
+	if (f.exceptionTypeCode) form.exceptionTypeCode = f.exceptionTypeCode;
+	if (f.severity) form.severity = f.severity;
+
+	// 2) 反查 BUSINESS_MONITOR_OPTIONS:按 (objectType, objectLabel) 在当前机制下定位 objectIndex
+	const candidates = BUSINESS_MONITOR_OPTIONS.filter((o) => o.mechanisms.includes(form.mechanism as any));
+	const idx = candidates.findIndex((o) => o.objectType === f.objectType && o.objectLabel === f.objectLabel);
+	if (idx >= 0) {
+		form.objectIndex = idx;
+		const metricExists = candidates[idx].metrics.some((mm) => mm.metricCode === f.metricCode && mm.mechanism === form.mechanism);
+		if (metricExists) form.metricCode = f.metricCode;
+		else dictionaryMissing.value = true;
+	} else {
+		// 字典缺失 — 保留 label 显示,禁止 generate
+		dictionaryMissing.value = true;
+		fallbackObjectLabel.value = f.objectLabel || labels.objectLabel || '';
+		fallbackMetricLabel.value = f.metricLabel || labels.metricLabel || '';
+	}
 
-	const dataSourceId = pickDataSourceId();
-	if (dataSourceId == null) { ElMessage.error('缺少数据源,无法创建规则'); return; }
+	if (typeof f.unit === 'string') form.unit = f.unit;
+	if (typeof f.graceMinutes === 'number') form.graceMinutes = f.graceMinutes;
+	if (typeof f.completedStates === 'string') form.completedStates = f.completedStates;
+	if (f.lowerBound !== undefined) form.lowerBound = f.lowerBound;
+	if (f.upperBound !== undefined) form.upperBound = f.upperBound;
+	if (typeof f.toleranceAbs === 'number') form.toleranceAbs = f.toleranceAbs;
+	if (typeof f.toleranceRatioPct === 'number') form.toleranceRatioPct = f.toleranceRatioPct;
+	if (typeof f.targetRatio === 'number') form.targetRatio = f.targetRatio;
+	if (typeof f.ruleCode === 'string') form.ruleCode = f.ruleCode;
+	if (typeof f.pollIntervalSeconds === 'number') form.pollIntervalSeconds = f.pollIntervalSeconds;
+	if (typeof f.triggerCountRequired === 'number') form.triggerCountRequired = f.triggerCountRequired;
+	if (typeof f.recoverCountRequired === 'number') form.recoverCountRequired = f.recoverCountRequired;
+
+	const wantStep = typeof parsed.step === 'number' ? parsed.step : (typeof draft.currentStep === 'number' ? draft.currentStep : 0);
+	step.value = Math.max(0, Math.min(4, wantStep));
+}
 
-	const obj = selectedObject.value;
-	if (!obj) { ElMessage.warning('监控对象异常'); return; }
+function buildDraftPayload(): {
+	tenantId: number;
+	factoryId: number;
+	draftCode: string;
+	draftName: string;
+	wizardJson: string;
+	currentStep: number;
+	mechanism: string;
+	stageCode: string;
+	orderFlowCode: string | null;
+	exceptionTypeCode: string;
+} {
+	const name = draftName.value.trim() || generateDraftName();
+	return {
+		tenantId: props.tenantId ?? 1,
+		factoryId: props.factoryId ?? 1,
+		draftCode: currentDraftCode.value || generateDraftCode(),
+		draftName: name,
+		wizardJson: serializeWizardState(),
+		currentStep: step.value,
+		mechanism: form.mechanism,
+		stageCode: form.stageCode,
+		orderFlowCode: form.orderFlowCode || null,
+		exceptionTypeCode: form.exceptionTypeCode,
+	};
+}
 
-	const payload: S8WatchRuleCreatePayload = {
+async function saveDraft(): Promise<boolean> {
+	if (isManualReport.value) {
+		ElMessage.info('主动提报无需保存草稿');
+		return false;
+	}
+	const payload = buildDraftPayload();
+	savingDraft.value = true;
+	try {
+		if (currentDraftId.value == null) {
+			const created = await s8ConfigApi.wizardDrafts.create(payload);
+			currentDraftId.value = created.id;
+			currentDraftCode.value = created.draftCode;
+			draftName.value = created.draftName ?? payload.draftName;
+			ElMessage.success('草稿已保存');
+		} else {
+			const updated = await s8ConfigApi.wizardDrafts.update(currentDraftId.value, {
+				draftName: payload.draftName,
+				wizardJson: payload.wizardJson,
+				currentStep: payload.currentStep,
+				mechanism: payload.mechanism || null,
+				stageCode: payload.stageCode || null,
+				orderFlowCode: payload.orderFlowCode,
+				exceptionTypeCode: payload.exceptionTypeCode || null,
+			});
+			draftName.value = updated.draftName ?? payload.draftName;
+			ElMessage.success('草稿已更新');
+		}
+		emit('draftSaved');
+		return true;
+	} catch (e: any) {
+		const msg = e?.response?.data?.message ?? e?.message ?? '保存草稿失败';
+		ElMessage.error(msg);
+		return false;
+	} finally {
+		savingDraft.value = false;
+	}
+}
+
+function buildRulePayload(): S8WatchRuleCreatePayload | null {
+	const dataSourceId = pickDataSourceId();
+	if (dataSourceId == null) { ElMessage.error('缺少数据源,无法生成规则'); return null; }
+	const obj = selectedObject.value;
+	if (!obj) { ElMessage.warning('监控对象异常'); return null; }
+	return {
 		tenantId: props.tenantId ?? 1,
 		factoryId: props.factoryId ?? 1,
 		ruleCode: form.ruleCode.trim(),
@@ -401,15 +610,45 @@ async function save() {
 		paramsJson: buildParamsJson(),
 		expression: buildExpression(),
 	};
+}
 
+function validateGenerate(): boolean {
+	if (!form.mechanism || isManualReport.value) { ElMessage.warning('未选择报警机制'); return false; }
+	if (!form.stageCode) { ElMessage.warning('阶段维度未选择'); return false; }
+	if (form.objectIndex < 0 || !form.metricCode) { ElMessage.warning('监控对象或监控指标未选择'); return false; }
+	if (!form.exceptionTypeCode) { ElMessage.warning('异常类型未选择'); return false; }
+	if (!form.ruleCode.trim()) { ElMessage.warning('规则编码不能为空'); return false; }
+	if (form.pollIntervalSeconds <= 0) { ElMessage.warning('轮询间隔必须大于 0'); return false; }
+	if (form.mechanism === 'DATE') {
+		const states = form.completedStates.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
+		if (states.length === 0) { ElMessage.warning('完成状态至少填一个'); return false; }
+	}
+	if (form.mechanism === 'VALUE_RANGE') {
+		if (form.lowerBound == null && form.upperBound == null) { ElMessage.warning('上下限至少填一个'); return false; }
+	}
+	if (form.mechanism === 'RATIO') {
+		if (!(form.targetRatio > 0 && form.targetRatio <= 100)) { ElMessage.warning('目标比例须在 0–100 之间'); return false; }
+	}
+	if (dictionaryMissing.value) { ElMessage.warning(dictionaryHint.value || '指标字典缺失,请重新选择'); return false; }
+	return true;
+}
+
+async function generateRuleFromDraft() {
+	if (!validateGenerate()) return;
 	saving.value = true;
 	try {
-		await s8ConfigApi.watchRules.create(payload);
-		ElMessage.success('规则已创建,默认停用');
+		const ok = await saveDraft();
+		if (!ok || currentDraftId.value == null) { saving.value = false; return; }
+		const payload = buildRulePayload();
+		if (!payload) { saving.value = false; return; }
+		payload.enabled = false; // 双保险
+		const result = await s8ConfigApi.wizardDrafts.generateRule(currentDraftId.value, { rulePayload: payload });
+		ElMessage.success(`正式规则已生成,默认停用(id=${result.ruleId})`);
+		emit('generated');
 		emit('saved');
 		visible.value = false;
 	} catch (e: any) {
-		const msg = e?.response?.data?.message ?? e?.message ?? '创建失败';
+		const msg = e?.response?.data?.message ?? e?.message ?? '生成规则失败';
 		ElMessage.error(msg);
 	} finally {
 		saving.value = false;
@@ -423,7 +662,7 @@ function goManualReport() {
 </script>
 
 <template>
-	<el-dialog v-model="visible" title="新建监控配置" width="680px" :close-on-click-modal="false" destroy-on-close>
+	<el-dialog v-model="visible" :title="currentDraftId ? '继续编辑草稿' : '新建监控配置'" width="680px" :close-on-click-modal="false" destroy-on-close>
 		<el-steps :active="isManualReport ? 0 : step" finish-status="success" simple style="margin-bottom: 16px">
 			<el-step title="报警机制" />
 			<el-step title="业务维度" />
@@ -432,6 +671,15 @@ function goManualReport() {
 			<el-step title="基础配置" />
 		</el-steps>
 
+		<el-alert
+			v-if="dictionaryMissing && !isManualReport"
+			type="warning"
+			show-icon
+			:closable="false"
+			style="margin-bottom: 12px"
+			:title="dictionaryHint"
+		/>
+
 		<!-- Step 0: 报警机制 -->
 		<div v-if="step === 0">
 			<div class="wizard-mech-grid">
@@ -480,7 +728,7 @@ function goManualReport() {
 					<el-select v-model="form.objectIndex" placeholder="选择监控对象" style="width: 100%">
 						<el-option
 							v-for="(o, idx) in filteredObjects"
-							:key="`${o.objectType}-${idx}`"
+							:key="`${o.objectType}-${o.objectLabel}-${idx}`"
 							:label="o.objectLabel"
 							:value="idx"
 						/>
@@ -503,7 +751,6 @@ function goManualReport() {
 		<!-- Step 3: 判定标准 -->
 		<div v-else-if="step === 3">
 			<el-form label-position="top" size="small">
-				<!-- DATE -->
 				<template v-if="form.mechanism === 'DATE'">
 					<el-alert type="info" :closable="false" show-icon title="判定逻辑">
 						<template #default>到期后仍未到达「完成状态」即触发。</template>
@@ -516,7 +763,6 @@ function goManualReport() {
 						<span class="wizard-hint">超过到期时间后再等待 N 分钟才触发,0 表示立即触发</span>
 					</el-form-item>
 				</template>
-				<!-- VALUE_RANGE -->
 				<template v-else-if="form.mechanism === 'VALUE_RANGE'">
 					<el-alert type="info" :closable="false" show-icon title="判定逻辑">
 						<template #default>低于下限或高于上限即触发;至少填写下限或上限之一。</template>
@@ -537,7 +783,6 @@ function goManualReport() {
 						<el-input-number v-model="form.toleranceRatioPct" :min="0" :max="100" :step="1" />
 					</el-form-item>
 				</template>
-				<!-- RATIO -->
 				<template v-else-if="form.mechanism === 'RATIO'">
 					<el-alert type="info" :closable="false" show-icon title="判定逻辑">
 						<template #default>实际比例低于目标比例即触发。</template>
@@ -572,8 +817,8 @@ function goManualReport() {
 				<el-descriptions-item label="报警机制">{{ mechanismLabel }}</el-descriptions-item>
 				<el-descriptions-item label="所属阶段">{{ stageNodeLabel }}</el-descriptions-item>
 				<el-descriptions-item label="订单流程">{{ orderFlowNodeLabel }}</el-descriptions-item>
-				<el-descriptions-item label="监控对象">{{ selectedObject?.objectLabel || '—' }}</el-descriptions-item>
-				<el-descriptions-item label="监控指标">{{ selectedMetric?.metricLabel || '—' }}</el-descriptions-item>
+				<el-descriptions-item label="监控对象">{{ selectedObject?.objectLabel || fallbackObjectLabel || '—' }}</el-descriptions-item>
+				<el-descriptions-item label="监控指标">{{ selectedMetric?.metricLabel || fallbackMetricLabel || '—' }}</el-descriptions-item>
 				<el-descriptions-item label="判定标准">{{ thresholdDisplay }}</el-descriptions-item>
 				<el-descriptions-item label="异常类型">{{ exceptionTypeLabel }}</el-descriptions-item>
 				<el-descriptions-item label="严重度">{{ severityLabel }}</el-descriptions-item>
@@ -584,6 +829,10 @@ function goManualReport() {
 			</el-descriptions>
 
 			<el-form label-position="top" size="small">
+				<el-form-item label="草稿名称">
+					<el-input v-model="draftName" placeholder="留空则自动生成" maxlength="128" show-word-limit />
+					<span class="wizard-hint">草稿名仅用于草稿配置列表展示,可随时修改</span>
+				</el-form-item>
 				<el-form-item label="规则编码" required>
 					<el-input v-model="form.ruleCode" :placeholder="ruleCodePlaceholder" maxlength="64" show-word-limit />
 				</el-form-item>
@@ -608,8 +857,9 @@ function goManualReport() {
 			<template v-else>
 				<el-button v-if="step > 0" @click="prevStep">上一步</el-button>
 				<el-button @click="visible = false">取消</el-button>
+				<el-button :loading="savingDraft" @click="saveDraft">保存草稿</el-button>
 				<el-button v-if="step < 4" type="primary" @click="nextStep">下一步</el-button>
-				<el-button v-else type="primary" :loading="saving" @click="save">保存(停用)</el-button>
+				<el-button v-else type="primary" :loading="saving" :disabled="dictionaryMissing" @click="generateRuleFromDraft">生成正式规则</el-button>
 			</template>
 		</template>
 	</el-dialog>

+ 277 - 0
Web/src/views/aidop/s8/config/components/WizardDraftDrawer.vue

@@ -0,0 +1,277 @@
+<script setup lang="ts" name="aidopS8WizardDraftDrawer">
+// CONFIG-WIZARD-DRAFT-FRONTEND-1:S8 草稿配置 drawer。
+// 展示草稿列表 + 状态 chip 切换 + 继续编辑 + 删除 + 显示 generated_rule_id。
+import { computed, ref, watch } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import {
+	s8ConfigApi,
+	type S8DimensionNode,
+	type S8ExceptionTypeConfigRow,
+	type S8WizardDraftListItem,
+	type S8WizardDraftStatus,
+} from '../../api/s8ConfigApi';
+
+const props = defineProps<{
+	modelValue: boolean;
+	tenantId: number;
+	factoryId: number;
+	stageNodes: S8DimensionNode[];
+	exceptionTypes: S8ExceptionTypeConfigRow[];
+}>();
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', value: boolean): void;
+	(e: 'editDraft', draftId: number | string): void;
+	(e: 'deleted'): void;
+	(e: 'refreshed'): void;
+}>();
+
+const visible = computed({
+	get: () => props.modelValue,
+	set: (v) => emit('update:modelValue', v),
+});
+
+const loading = ref(false);
+const drafts = ref<S8WizardDraftListItem[]>([]);
+const total = ref(0);
+const page = ref(1);
+const pageSize = ref(20);
+const statusFilter = ref<'' | S8WizardDraftStatus>('');
+
+const STATUS_TABS: Array<{ value: '' | S8WizardDraftStatus; label: string }> = [
+	{ value: '', label: '全部' },
+	{ value: 'IN_PROGRESS', label: '配置中' },
+	{ value: 'GENERATED', label: '已生成' },
+];
+
+const MECHANISM_LABEL: Record<string, string> = {
+	DATE: '日期类',
+	VALUE_RANGE: '数值超差类',
+	RATIO: '比例类',
+	MANUAL_REPORT: '主动提报',
+};
+
+function statusLabel(status: S8WizardDraftStatus): string {
+	if (status === 'IN_PROGRESS') return '配置中';
+	if (status === 'GENERATED') return '已生成规则';
+	return status || '—';
+}
+
+function statusTagType(status: S8WizardDraftStatus): 'success' | 'info' | 'warning' | '' {
+	if (status === 'GENERATED') return 'success';
+	if (status === 'IN_PROGRESS') return 'warning';
+	return 'info';
+}
+
+function mechanismChinese(m?: string | null): string {
+	if (!m) return '—';
+	return MECHANISM_LABEL[m] ?? m;
+}
+
+function stageChinese(code?: string | null): string {
+	if (!code) return '—';
+	return props.stageNodes.find((n) => n.nodeCode === code)?.nodeName ?? code;
+}
+
+function exceptionTypeChinese(code?: string | null): string {
+	if (!code) return '—';
+	return props.exceptionTypes.find((t) => t.typeCode === code)?.typeName ?? code;
+}
+
+function fmtTime(s?: string | null): string {
+	if (!s) return '—';
+	return s;
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const res = await s8ConfigApi.wizardDrafts.list({
+			tenantId: props.tenantId,
+			factoryId: props.factoryId,
+			status: statusFilter.value || undefined,
+			page: page.value,
+			pageSize: pageSize.value,
+		});
+		drafts.value = res.items ?? [];
+		total.value = Number(res.total ?? 0);
+	} catch (e: any) {
+		const msg = e?.response?.data?.message ?? e?.message ?? '加载草稿失败';
+		ElMessage.error(msg);
+		drafts.value = [];
+		total.value = 0;
+	} finally {
+		loading.value = false;
+		emit('refreshed');
+	}
+}
+
+watch(visible, (v) => {
+	if (v) {
+		page.value = 1;
+		loadList();
+	}
+});
+
+watch(statusFilter, () => {
+	page.value = 1;
+	loadList();
+});
+
+watch([page, pageSize], () => {
+	loadList();
+});
+
+function handleEdit(row: S8WizardDraftListItem) {
+	if (row.status === 'GENERATED') {
+		ElMessage.info('草稿已生成正式规则,无法继续编辑');
+		return;
+	}
+	emit('editDraft', row.id);
+}
+
+async function handleDelete(row: S8WizardDraftListItem) {
+	const isGenerated = row.status === 'GENERATED';
+	const tip = isGenerated
+		? '该草稿已生成正式规则,删除草稿不会删除已生成的规则。是否继续?'
+		: `确认删除草稿「${row.draftName || row.draftCode}」?`;
+	try {
+		await ElMessageBox.confirm(tip, '提示', { type: 'warning' });
+	} catch {
+		return;
+	}
+	try {
+		await s8ConfigApi.wizardDrafts.remove(row.id);
+		ElMessage.success('草稿已删除');
+		emit('deleted');
+		await loadList();
+	} catch (e: any) {
+		const msg = e?.response?.data?.message ?? e?.message ?? '删除失败';
+		ElMessage.error(msg);
+	}
+}
+
+defineExpose({ loadList });
+</script>
+
+<template>
+	<el-drawer
+		v-model="visible"
+		title="草稿配置"
+		size="600px"
+		direction="rtl"
+		:close-on-click-modal="false"
+	>
+		<div class="draft-drawer">
+			<div class="draft-drawer__filters">
+				<el-radio-group v-model="statusFilter" size="small">
+					<el-radio-button v-for="t in STATUS_TABS" :key="t.value || 'all'" :value="t.value">{{ t.label }}</el-radio-button>
+				</el-radio-group>
+				<el-button size="small" :loading="loading" @click="loadList">刷新</el-button>
+			</div>
+
+			<div v-loading="loading" class="draft-drawer__list">
+				<el-empty v-if="!loading && drafts.length === 0" description="暂无草稿配置" />
+				<div v-for="row in drafts" :key="String(row.id)" class="draft-card">
+					<div class="draft-card__head">
+						<div class="draft-card__title" :title="row.draftName || row.draftCode">
+							{{ row.draftName || row.draftCode }}
+						</div>
+						<el-tag size="small" :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
+					</div>
+					<div class="draft-card__meta">
+						<span>机制:{{ mechanismChinese(row.mechanism) }}</span>
+						<span>阶段:{{ stageChinese(row.stageCode) }}</span>
+						<span>异常类型:{{ exceptionTypeChinese(row.exceptionTypeCode) }}</span>
+					</div>
+					<div class="draft-card__meta">
+						<span>当前步骤:{{ row.currentStep ?? 0 }}</span>
+						<span v-if="row.generatedRuleId">已生成规则 id:{{ row.generatedRuleId }}</span>
+						<span>更新时间:{{ fmtTime(row.updatedAt || row.createdAt) }}</span>
+					</div>
+					<div class="draft-card__actions">
+						<el-button
+							size="small"
+							type="primary"
+							:disabled="row.status === 'GENERATED'"
+							@click="handleEdit(row)"
+						>继续编辑</el-button>
+						<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
+					</div>
+				</div>
+			</div>
+
+			<div class="draft-drawer__pager">
+				<el-pagination
+					v-model:current-page="page"
+					v-model:page-size="pageSize"
+					:page-sizes="[20, 50, 100]"
+					:total="total"
+					layout="total, sizes, prev, pager, next"
+					small
+					background
+				/>
+			</div>
+		</div>
+	</el-drawer>
+</template>
+
+<style scoped>
+.draft-drawer {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	gap: 12px;
+}
+.draft-drawer__filters {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+}
+.draft-drawer__list {
+	flex: 1;
+	overflow-y: auto;
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	min-height: 200px;
+}
+.draft-card {
+	border: 1px solid var(--el-border-color);
+	border-radius: 6px;
+	padding: 12px 14px;
+	background: var(--el-fill-color-blank);
+}
+.draft-card__head {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	gap: 12px;
+	margin-bottom: 6px;
+}
+.draft-card__title {
+	font-weight: 600;
+	font-size: 14px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.draft-card__meta {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 12px;
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+	margin-bottom: 4px;
+}
+.draft-card__actions {
+	display: flex;
+	justify-content: flex-end;
+	gap: 8px;
+	margin-top: 6px;
+}
+.draft-drawer__pager {
+	display: flex;
+	justify-content: flex-end;
+}
+</style>