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

feat(s8): add rule configuration UI

YY968XX 1 месяц назад
Родитель
Сommit
81ee237cc4

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

@@ -68,6 +68,32 @@ export interface S8DashboardCellConfigRow {
 
 export type S8DashboardCellConfigPayload = Omit<S8DashboardCellConfigRow, 'id' | 'createdAt' | 'updatedAt'>;
 
+export type S8WatchRuleType = 'TIMEOUT' | 'SHORTAGE' | 'OUT_OF_RANGE' | string;
+
+export interface S8WatchRuleConfigRow {
+	id: number | string;
+	tenantId: number | string;
+	factoryId: number | string;
+	ruleCode: string;
+	sceneCode: string;
+	dataSourceId: number | string;
+	watchObjectType: string;
+	expression?: string | null;
+	severity: string;
+	pollIntervalSeconds: number;
+	enabled: boolean;
+	createdAt: string;
+	updatedAt?: string | null;
+	ruleType?: S8WatchRuleType | null;
+	sourceObjectType?: string | null;
+	paramsJson?: string | null;
+}
+
+export interface S8WatchRuleParamsPayload {
+	paramsJson?: string | null;
+	enabled: boolean;
+}
+
 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),
@@ -105,6 +131,12 @@ export const s8ConfigApi = {
 		remove: (id: number) =>
 			service.delete(`/api/aidop/s8/config/exception-types/${id}`).then(unwrap),
 	},
+	watchRules: {
+		list: (params?: { tenantId?: number; factoryId?: number }) =>
+			service.get<S8WatchRuleConfigRow[]>('/api/aidop/s8/config/watch-rules', { params }).then(unwrap),
+		updateParams: (id: number | string, body: S8WatchRuleParamsPayload) =>
+			service.put<S8WatchRuleConfigRow>(`/api/aidop/s8/config/watch-rules/${id}/params`, body).then(unwrap),
+	},
 	dashboardCells: {
 		list: (params?: { tenantId?: number; factoryId?: number }) =>
 			service.get<S8DashboardCellConfigRow[]>('/api/aidop/s8/config/dashboard-cells', { params }).then(unwrap),

+ 489 - 54
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -1,62 +1,497 @@
 <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,
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import {
+	s8ConfigApi,
+	type S8ExceptionTypeConfigRow,
+	type S8WatchRuleConfigRow,
+	type S8WatchRuleType,
+} from '../api/s8ConfigApi';
+
+const TENANT_ID = 1;
+const FACTORY_ID = 1;
+
+const loading = ref(false);
+const saving = ref(false);
+const rows = ref<S8WatchRuleConfigRow[]>([]);
+const exceptionTypes = ref<S8ExceptionTypeConfigRow[]>([]);
+
+const drawerOpen = ref(false);
+const editingRow = ref<S8WatchRuleConfigRow | null>(null);
+
+interface TimeoutForm {
+	dueAtField: string;
+	statusField: string;
+	completedStates: string;
+	objectCodeField: string;
+	objectIdField: string;
+	graceMinutes: number;
+	exceptionTypeCode: string;
+}
+interface ShortageForm {
+	targetQtyField: string;
+	actualQtyField: string;
+	objectCodeField: string;
+	objectIdField: string;
+	toleranceAbs: number;
+	toleranceRatio: number;
+	exceptionTypeCode: string;
+}
+interface OutOfRangeForm {
+	measuredValueField: string;
+	objectCodeField: string;
+	objectIdField: string;
+	lowerBound: number | null;
+	upperBound: number | null;
+	lowerBoundField: string;
+	upperBoundField: string;
+	toleranceAbs: number;
+	toleranceRatio: number;
+	exceptionTypeCode: string;
+}
+
+const timeoutForm = reactive<TimeoutForm>({
+	dueAtField: '',
+	statusField: '',
+	completedStates: '',
+	objectCodeField: '',
+	objectIdField: '',
+	graceMinutes: 0,
+	exceptionTypeCode: '',
+});
+const shortageForm = reactive<ShortageForm>({
+	targetQtyField: '',
+	actualQtyField: '',
+	objectCodeField: '',
+	objectIdField: '',
+	toleranceAbs: 0,
+	toleranceRatio: 0,
+	exceptionTypeCode: '',
+});
+const outOfRangeForm = reactive<OutOfRangeForm>({
+	measuredValueField: '',
+	objectCodeField: '',
+	objectIdField: '',
+	lowerBound: null,
+	upperBound: null,
+	lowerBoundField: '',
+	upperBoundField: '',
+	toleranceAbs: 0,
+	toleranceRatio: 0,
+	exceptionTypeCode: '',
+});
+const enabledForm = ref(true);
+const rawParamsJson = ref<string>('');
+
+const ruleTypeOf = (row: S8WatchRuleConfigRow | null): S8WatchRuleType | '' =>
+	(row?.ruleType ?? '').toUpperCase();
+
+const formTitle = computed(() => {
+	if (!editingRow.value) return '编辑规则参数';
+	return `编辑规则参数 — ${editingRow.value.ruleCode}`;
 });
 
-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 })),
-	};
+const ruleTypeLabel = (t?: string | null) => {
+	switch ((t ?? '').toUpperCase()) {
+		case 'TIMEOUT': return '超时';
+		case 'SHORTAGE': return '数量不足';
+		case 'OUT_OF_RANGE': return '数据超差';
+		default: return t || '—';
+	}
+};
+
+const exceptionTypeOptions = computed(() =>
+	exceptionTypes.value
+		.filter((t) => t.enabled)
+		.map((t) => ({ label: `${t.typeName} (${t.typeCode})`, value: t.typeCode })),
+);
+
+async function loadRows() {
+	loading.value = true;
+	try {
+		rows.value = await s8ConfigApi.watchRules.list({ tenantId: TENANT_ID, factoryId: FACTORY_ID });
+	} catch (e: any) {
+		ElMessage.error(e?.message ?? '加载监视规则失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadExceptionTypes() {
+	try {
+		exceptionTypes.value = await s8ConfigApi.exceptionTypes.list({
+			tenantId: TENANT_ID,
+			factoryId: FACTORY_ID,
+			enabledOnly: true,
+		});
+	} catch (e: any) {
+		ElMessage.error(e?.message ?? '加载异常类型失败');
+	}
+}
+
+function resetForms() {
+	Object.assign(timeoutForm, {
+		dueAtField: '', statusField: '', completedStates: '',
+		objectCodeField: '', objectIdField: '',
+		graceMinutes: 0, exceptionTypeCode: '',
+	});
+	Object.assign(shortageForm, {
+		targetQtyField: '', actualQtyField: '',
+		objectCodeField: '', objectIdField: '',
+		toleranceAbs: 0, toleranceRatio: 0, exceptionTypeCode: '',
+	});
+	Object.assign(outOfRangeForm, {
+		measuredValueField: '', objectCodeField: '', objectIdField: '',
+		lowerBound: null, upperBound: null,
+		lowerBoundField: '', upperBoundField: '',
+		toleranceAbs: 0, toleranceRatio: 0, exceptionTypeCode: '',
+	});
+}
+
+function loadFormFromRow(row: S8WatchRuleConfigRow) {
+	resetForms();
+	enabledForm.value = !!row.enabled;
+	rawParamsJson.value = row.paramsJson ?? '';
+
+	if (!row.paramsJson) return;
+	let parsed: Record<string, any>;
+	try {
+		parsed = JSON.parse(row.paramsJson);
+	} catch {
+		ElMessage.warning('当前 params_json 不是合法 JSON,将以空表单展示');
+		return;
+	}
+	const t = ruleTypeOf(row);
+	if (t === 'TIMEOUT') {
+		Object.assign(timeoutForm, {
+			dueAtField: parsed.dueAtField ?? '',
+			statusField: parsed.statusField ?? '',
+			completedStates: Array.isArray(parsed.completedStates) ? parsed.completedStates.join(',') : '',
+			objectCodeField: parsed.objectCodeField ?? '',
+			objectIdField: parsed.objectIdField ?? '',
+			graceMinutes: typeof parsed.graceMinutes === 'number' ? parsed.graceMinutes : 0,
+			exceptionTypeCode: parsed.exceptionTypeCode ?? '',
+		});
+	} else if (t === 'SHORTAGE') {
+		Object.assign(shortageForm, {
+			targetQtyField: parsed.targetQtyField ?? '',
+			actualQtyField: parsed.actualQtyField ?? '',
+			objectCodeField: parsed.objectCodeField ?? '',
+			objectIdField: parsed.objectIdField ?? '',
+			toleranceAbs: typeof parsed.toleranceAbs === 'number' ? parsed.toleranceAbs : 0,
+			toleranceRatio: typeof parsed.toleranceRatio === 'number' ? parsed.toleranceRatio : 0,
+			exceptionTypeCode: parsed.exceptionTypeCode ?? '',
+		});
+	} else if (t === 'OUT_OF_RANGE') {
+		// 兼容旧 single_threshold:只读展示,保存时转为新协议。
+		const measured = parsed.measuredValueField ?? parsed.valueField ?? '';
+		let lower: number | null = parsed.lowerBound ?? null;
+		let upper: number | null = parsed.upperBound ?? null;
+		if (parsed.mode === 'single_threshold' && typeof parsed.threshold === 'number') {
+			const op = String(parsed.operator ?? '').trim();
+			if (op === '>' || op === '>=') upper = upper ?? parsed.threshold;
+			else if (op === '<' || op === '<=') lower = lower ?? parsed.threshold;
+		}
+		Object.assign(outOfRangeForm, {
+			measuredValueField: measured,
+			objectCodeField: parsed.objectCodeField ?? '',
+			objectIdField: parsed.objectIdField ?? '',
+			lowerBound: lower,
+			upperBound: upper,
+			lowerBoundField: parsed.lowerBoundField ?? '',
+			upperBoundField: parsed.upperBoundField ?? '',
+			toleranceAbs: typeof parsed.toleranceAbs === 'number' ? parsed.toleranceAbs : 0,
+			toleranceRatio: typeof parsed.toleranceRatio === 'number' ? parsed.toleranceRatio : 0,
+			exceptionTypeCode: parsed.exceptionTypeCode ?? 'EQUIP_FAULT',
+		});
+	}
+}
+
+function openEdit(row: S8WatchRuleConfigRow) {
+	editingRow.value = row;
+	loadFormFromRow(row);
+	drawerOpen.value = true;
 }
+
+function closeDrawer() {
+	drawerOpen.value = false;
+	editingRow.value = null;
+}
+
+function buildPayloadParamsJson(): string | null {
+	const t = ruleTypeOf(editingRow.value);
+	if (t === 'TIMEOUT') {
+		const states = timeoutForm.completedStates
+			.split(/[,\s]+/)
+			.map((s) => s.trim())
+			.filter(Boolean);
+		return JSON.stringify({
+			dueAtField: timeoutForm.dueAtField,
+			statusField: timeoutForm.statusField,
+			completedStates: states,
+			objectCodeField: timeoutForm.objectCodeField,
+			objectIdField: timeoutForm.objectIdField,
+			graceMinutes: Number(timeoutForm.graceMinutes) || 0,
+			exceptionTypeCode: timeoutForm.exceptionTypeCode,
+		});
+	}
+	if (t === 'SHORTAGE') {
+		return JSON.stringify({
+			targetQtyField: shortageForm.targetQtyField,
+			actualQtyField: shortageForm.actualQtyField,
+			objectCodeField: shortageForm.objectCodeField,
+			objectIdField: shortageForm.objectIdField,
+			toleranceAbs: Number(shortageForm.toleranceAbs) || 0,
+			toleranceRatio: Number(shortageForm.toleranceRatio) || 0,
+			exceptionTypeCode: shortageForm.exceptionTypeCode,
+		});
+	}
+	if (t === 'OUT_OF_RANGE') {
+		return JSON.stringify({
+			measuredValueField: outOfRangeForm.measuredValueField,
+			objectCodeField: outOfRangeForm.objectCodeField,
+			objectIdField: outOfRangeForm.objectIdField,
+			lowerBound: outOfRangeForm.lowerBound,
+			upperBound: outOfRangeForm.upperBound,
+			lowerBoundField: outOfRangeForm.lowerBoundField || undefined,
+			upperBoundField: outOfRangeForm.upperBoundField || undefined,
+			toleranceAbs: Number(outOfRangeForm.toleranceAbs) || 0,
+			toleranceRatio: Number(outOfRangeForm.toleranceRatio) || 0,
+			exceptionTypeCode: outOfRangeForm.exceptionTypeCode,
+		});
+	}
+	return rawParamsJson.value || null;
+}
+
+async function save() {
+	if (!editingRow.value) return;
+	saving.value = true;
+	try {
+		const payload = {
+			paramsJson: buildPayloadParamsJson(),
+			enabled: enabledForm.value,
+		};
+		await s8ConfigApi.watchRules.updateParams(editingRow.value.id, payload);
+		ElMessage.success('保存成功');
+		closeDrawer();
+		await loadRows();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? e?.message ?? '保存失败');
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function toggleEnabled(row: S8WatchRuleConfigRow) {
+	const next = !row.enabled;
+	const action = next ? '启用' : '停用';
+	try {
+		await ElMessageBox.confirm(`确认${action}规则 ${row.ruleCode}?`, '提示', { type: 'warning' });
+	} catch { return; }
+	try {
+		await s8ConfigApi.watchRules.updateParams(row.id, {
+			paramsJson: row.paramsJson ?? null,
+			enabled: next,
+		});
+		ElMessage.success(`${action}成功`);
+		await loadRows();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? e?.message ?? `${action}失败`);
+	}
+}
+
+onMounted(() => {
+	loadRows();
+	loadExceptionTypes();
+});
 </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"
-	/>
+	<AidopDemoShell title="监视规则配置" subtitle="S8 / 配置 / 监视规则(仅模板化参数编辑,不开放 SQL 编辑)">
+		<el-table v-loading="loading" :data="rows" border stripe size="small">
+			<el-table-column prop="ruleCode" label="规则编码" width="170" />
+			<el-table-column label="规则类型" width="120">
+				<template #default="{ row }">
+					<el-tag :type="row.ruleType ? 'success' : 'info'" size="small">
+						{{ ruleTypeLabel(row.ruleType) }}
+					</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="sceneCode" label="场景" width="160" />
+			<el-table-column prop="sourceObjectType" label="源对象" width="120">
+				<template #default="{ row }">{{ row.sourceObjectType ?? row.watchObjectType ?? '—' }}</template>
+			</el-table-column>
+			<el-table-column label="启用" width="90">
+				<template #default="{ row }">
+					<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
+						{{ row.enabled ? '启用' : '停用' }}
+					</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="updatedAt" label="最近更新" width="170">
+				<template #default="{ row }">{{ row.updatedAt ?? row.createdAt ?? '—' }}</template>
+			</el-table-column>
+			<el-table-column label="操作" width="180" fixed="right">
+				<template #default="{ row }">
+					<el-button size="small" type="primary" link @click="openEdit(row)">编辑参数</el-button>
+					<el-button size="small" :type="row.enabled ? 'warning' : 'success'" link @click="toggleEnabled(row)">
+						{{ row.enabled ? '停用' : '启用' }}
+					</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<el-drawer v-model="drawerOpen" :title="formTitle" size="520px" direction="rtl" :before-close="(done: () => void) => { closeDrawer(); done(); }">
+			<div v-if="editingRow" class="rule-edit">
+				<el-descriptions :column="1" size="small" border class="rule-meta">
+					<el-descriptions-item label="规则编码">{{ editingRow.ruleCode }}</el-descriptions-item>
+					<el-descriptions-item label="规则类型">{{ ruleTypeLabel(editingRow.ruleType) }}</el-descriptions-item>
+					<el-descriptions-item label="场景">{{ editingRow.sceneCode }}</el-descriptions-item>
+					<el-descriptions-item label="源对象">{{ editingRow.sourceObjectType ?? editingRow.watchObjectType ?? '—' }}</el-descriptions-item>
+					<el-descriptions-item label="表达式(只读)">
+						<pre class="rule-expr">{{ editingRow.expression || '—' }}</pre>
+					</el-descriptions-item>
+				</el-descriptions>
+
+				<el-divider content-position="left">参数</el-divider>
+
+				<el-form v-if="ruleTypeOf(editingRow) === 'TIMEOUT'" label-position="top" size="small">
+					<el-form-item label="dueAtField(到期时间字段名)">
+						<el-input v-model="timeoutForm.dueAtField" />
+					</el-form-item>
+					<el-form-item label="statusField(状态字段名)">
+						<el-input v-model="timeoutForm.statusField" />
+					</el-form-item>
+					<el-form-item label="completedStates(完成状态,逗号分隔)">
+						<el-input v-model="timeoutForm.completedStates" placeholder="如 DONE,CLOSED" />
+					</el-form-item>
+					<el-form-item label="objectCodeField">
+						<el-input v-model="timeoutForm.objectCodeField" />
+					</el-form-item>
+					<el-form-item label="objectIdField">
+						<el-input v-model="timeoutForm.objectIdField" />
+					</el-form-item>
+					<el-form-item label="graceMinutes(宽限分钟,≥0)">
+						<el-input-number v-model="timeoutForm.graceMinutes" :min="0" />
+					</el-form-item>
+					<el-form-item label="exceptionTypeCode">
+						<el-select v-model="timeoutForm.exceptionTypeCode" filterable placeholder="选择异常类型">
+							<el-option v-for="o in exceptionTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+				</el-form>
+
+				<el-form v-else-if="ruleTypeOf(editingRow) === 'SHORTAGE'" label-position="top" size="small">
+					<el-form-item label="targetQtyField">
+						<el-input v-model="shortageForm.targetQtyField" />
+					</el-form-item>
+					<el-form-item label="actualQtyField">
+						<el-input v-model="shortageForm.actualQtyField" />
+					</el-form-item>
+					<el-form-item label="objectCodeField">
+						<el-input v-model="shortageForm.objectCodeField" />
+					</el-form-item>
+					<el-form-item label="objectIdField">
+						<el-input v-model="shortageForm.objectIdField" />
+					</el-form-item>
+					<el-form-item label="toleranceAbs(绝对容差,≥0)">
+						<el-input-number v-model="shortageForm.toleranceAbs" :min="0" :step="0.1" />
+					</el-form-item>
+					<el-form-item label="toleranceRatio(比例容差,0–1)">
+						<el-input-number v-model="shortageForm.toleranceRatio" :min="0" :max="1" :step="0.01" />
+					</el-form-item>
+					<el-form-item label="exceptionTypeCode">
+						<el-select v-model="shortageForm.exceptionTypeCode" filterable placeholder="选择异常类型">
+							<el-option v-for="o in exceptionTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+				</el-form>
+
+				<el-form v-else-if="ruleTypeOf(editingRow) === 'OUT_OF_RANGE'" label-position="top" size="small">
+					<el-form-item label="measuredValueField">
+						<el-input v-model="outOfRangeForm.measuredValueField" />
+					</el-form-item>
+					<el-form-item label="objectCodeField">
+						<el-input v-model="outOfRangeForm.objectCodeField" />
+					</el-form-item>
+					<el-form-item label="objectIdField">
+						<el-input v-model="outOfRangeForm.objectIdField" />
+					</el-form-item>
+					<el-form-item label="upperBound(固定上限,可空)">
+						<el-input-number v-model="outOfRangeForm.upperBound" :step="0.1" />
+					</el-form-item>
+					<el-form-item label="lowerBound(固定下限,可空)">
+						<el-input-number v-model="outOfRangeForm.lowerBound" :step="0.1" />
+					</el-form-item>
+					<el-form-item label="upperBoundField(行内上限字段,可空)">
+						<el-input v-model="outOfRangeForm.upperBoundField" />
+					</el-form-item>
+					<el-form-item label="lowerBoundField(行内下限字段,可空)">
+						<el-input v-model="outOfRangeForm.lowerBoundField" />
+					</el-form-item>
+					<el-form-item label="toleranceAbs(绝对容差,≥0)">
+						<el-input-number v-model="outOfRangeForm.toleranceAbs" :min="0" :step="0.1" />
+					</el-form-item>
+					<el-form-item label="toleranceRatio(比例容差,0–1)">
+						<el-input-number v-model="outOfRangeForm.toleranceRatio" :min="0" :max="1" :step="0.01" />
+					</el-form-item>
+					<el-form-item label="exceptionTypeCode">
+						<el-select v-model="outOfRangeForm.exceptionTypeCode" filterable placeholder="选择异常类型">
+							<el-option v-for="o in exceptionTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+				</el-form>
+
+				<el-alert v-else type="warning" :closable="false" show-icon>
+					当前规则 rule_type 为空或非三类已知值,本页面仅展示原始 params_json,不支持模板化编辑。
+					<pre class="rule-raw">{{ rawParamsJson || '(空)' }}</pre>
+				</el-alert>
+
+				<el-divider content-position="left">启用</el-divider>
+				<el-form label-position="top" size="small">
+					<el-form-item label="启用状态">
+						<el-switch v-model="enabledForm" />
+					</el-form-item>
+				</el-form>
+
+				<div class="rule-edit__footer">
+					<el-button @click="closeDrawer">取消</el-button>
+					<el-button type="primary" :loading="saving" @click="save">保存</el-button>
+				</div>
+			</div>
+		</el-drawer>
+	</AidopDemoShell>
 </template>
+
+<style scoped>
+.rule-edit {
+	padding: 0 4px 80px;
+}
+.rule-meta {
+	margin-bottom: 12px;
+}
+.rule-expr {
+	white-space: pre-wrap;
+	word-break: break-all;
+	margin: 0;
+	font-family: var(--el-font-family-mono, monospace);
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+.rule-raw {
+	white-space: pre-wrap;
+	word-break: break-all;
+	font-size: 12px;
+}
+.rule-edit__footer {
+	position: absolute;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	padding: 12px 16px;
+	background: var(--el-bg-color);
+	border-top: 1px solid var(--el-border-color-light);
+	text-align: right;
+}
+</style>

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

@@ -43,4 +43,16 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         try { return Ok(await _svc.TestAsync(id)); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
+
+    /// <summary>
+    /// R4 安全更新:仅修改 params_json 与 enabled,绝不接受 expression / rule_code / data_source_id /
+    /// scene_code / watch_object_type / rule_type / source_object_type 等敏感字段。
+    /// 服务端按 rule_type 用对应 evaluator 的 Params.Parse 进行 schema 校验。
+    /// </summary>
+    [HttpPut("{id:long}/params")]
+    public async Task<IActionResult> UpdateParamsAsync(long id, [FromBody] S8WatchRuleParamsPayload body)
+    {
+        try { return Ok(await _svc.UpdateParamsAsync(id, body)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 }

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleParamsPayload.cs

@@ -0,0 +1,15 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// R4 监视规则配置 UI 安全更新载荷。
+/// 仅承载白名单字段;expression / rule_code / data_source_id / scene_code / watch_object_type
+/// 不在该接口暴露,业务用户无法通过此路径修改 SQL 表达式或绑定关系。
+/// </summary>
+public sealed class S8WatchRuleParamsPayload
+{
+    /// <summary>params_json,由服务端按 RuleType 校验是否可被对应 evaluator 解析。</summary>
+    public string? ParamsJson { get; set; }
+
+    /// <summary>启用 / 停用。</summary>
+    public bool Enabled { get; set; }
+}

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

@@ -1,4 +1,6 @@
+using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -45,6 +47,75 @@ public class S8WatchRuleService : ITransient
 
     public async Task DeleteAsync(long id) => await _rep.DeleteByIdAsync(id);
 
+    /// <summary>
+    /// R4 安全更新:只更新 params_json 与 enabled。expression / rule_code / data_source_id /
+    /// scene_code / watch_object_type / rule_type / source_object_type 一律不通过此路径修改。
+    /// 当 RuleType 非空时,按对应 evaluator 的 Params.Parse 进行 schema 校验,解析失败抛 S8BizException。
+    /// </summary>
+    public async Task<AdoS8WatchRule> UpdateParamsAsync(long id, S8WatchRuleParamsPayload payload)
+    {
+        var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+
+        var paramsJson = payload.ParamsJson?.Trim();
+        if (!string.IsNullOrEmpty(paramsJson))
+        {
+            ValidateParamsJsonByRuleType(entity.RuleType, paramsJson);
+        }
+
+        entity.ParamsJson = string.IsNullOrEmpty(paramsJson) ? null : paramsJson;
+        entity.Enabled = payload.Enabled;
+        entity.UpdatedAt = DateTime.Now;
+        await _rep.UpdateAsync(entity);
+        return entity;
+    }
+
+    private static void ValidateParamsJsonByRuleType(string? ruleType, string paramsJson)
+    {
+        try
+        {
+            switch (ruleType)
+            {
+                case S8TimeoutRuleEvaluator.RuleTypeCode:
+                    {
+                        var p = S8TimeoutParams.Parse(paramsJson);
+                        if (string.IsNullOrWhiteSpace(p.DueAtField)
+                            || string.IsNullOrWhiteSpace(p.StatusField)
+                            || string.IsNullOrWhiteSpace(p.ExceptionTypeCode))
+                            throw new S8BizException("TIMEOUT params 缺少必填字段:dueAtField / statusField / exceptionTypeCode");
+                        break;
+                    }
+                case S8ShortageRuleEvaluator.RuleTypeCode:
+                    {
+                        var p = S8ShortageParams.Parse(paramsJson);
+                        if (string.IsNullOrWhiteSpace(p.TargetQtyField)
+                            || string.IsNullOrWhiteSpace(p.ActualQtyField)
+                            || string.IsNullOrWhiteSpace(p.ExceptionTypeCode))
+                            throw new S8BizException("SHORTAGE params 缺少必填字段:targetQtyField / actualQtyField / exceptionTypeCode");
+                        break;
+                    }
+                case S8OutOfRangeRuleEvaluator.RuleTypeCode:
+                    {
+                        var p = S8OutOfRangeParams.Parse(paramsJson);
+                        if (string.IsNullOrWhiteSpace(p.MeasuredValueField))
+                            throw new S8BizException("OUT_OF_RANGE params 缺少必填字段:measuredValueField");
+                        if (p.LowerBound == null && p.UpperBound == null
+                            && string.IsNullOrWhiteSpace(p.LowerBoundField)
+                            && string.IsNullOrWhiteSpace(p.UpperBoundField))
+                            throw new S8BizException("OUT_OF_RANGE params 必须提供 upperBound / lowerBound 或对应行内字段之一");
+                        break;
+                    }
+                default:
+                    // RuleType 为空或非三类已知值:仅做 JSON 合法性校验,避免阻塞历史数据。
+                    using (JsonDocument.Parse(paramsJson)) { }
+                    break;
+            }
+        }
+        catch (JsonException ex)
+        {
+            throw new S8BizException($"params_json 不是合法 JSON:{ex.Message}");
+        }
+    }
+
     public async Task<object> TestAsync(long id)
     {
         var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");