Procházet zdrojové kódy

feat(s8): expose scheduler controls in watch rule config

S8-SCHED-FRONTEND-1:监控规则配置页接通调度操作 + 运行态展示。

后端:
- AdoS8ConfigWatchRulesController 新增 4 端点:
  PUT  /api/aidop/s8/config/watch-rules/{id}/schedule
  POST /api/aidop/s8/config/watch-rules/{id}/run-now
  POST /api/aidop/s8/config/watch-rules/{id}/pause
  POST /api/aidop/s8/config/watch-rules/{id}/resume
- S8WatchRuleService 新增 UpdateScheduleAsync / RunNowAsync / PauseAsync / ResumeAsync;
  仅 SetColumns 局部更新调度运行态字段,不触碰 params_json / rule_type / expression / data_source_id
- run-now:next_run_at = NOW(不同步执行;下个 tick 拾取)
- pause:paused_until = 9999-12-31 + pause_reason = MANUAL_PAUSED;不强杀 lease
- resume:清 paused_until / pause_reason / last_error;归零 consecutive_failure_count;next_run_at = NOW
- schedule:仅更新 poll/trigger/recover;范围校验 60–86400 / 1–10 / 1–10
- 新增 S8WatchRuleSchedulePayload(仅 3 字段)

前端:
- S8WatchRuleConfigRow 扩展 13 个运行态字段(camelCase)
- s8ConfigApi.watchRules 新增 updateSchedule / runNow / pause / resume
- S8WatchRuleConfigPage 列表新增 9 列(轮询间隔/触发阈值/恢复阈值/上次执行/下次执行/上次状态/失败次数/耗时/错误摘要)
- 行操作新增 立即执行 / 暂停 / 恢复 按钮,状态感知(已暂停 / 执行中 / 失败次数 > 0)
- 编辑抽屉新增「调度设置」区(poll 预设下拉 + trigger/recover 数字输入)
- save 双端点:先 PUT /schedule 再 PUT /params;不破坏模板化 params_json 保存语义
- 30s 自动刷新(onMounted/onActivated 启 + onUnmounted/onDeactivated 清;编辑抽屉打开或页面不可见时跳过)

验证:
- run-now / pause / resume / schedule 四链路 Chrome MCP 端到端通过
- SQL 保存前后比对:rule_type/expression/params_json 数据不变;trigger_count_required 1→2 按预期生效
- baseline=3 守恒;demo 规则 enabled=1 / paused_until=NULL 守恒

不破坏:ApprovalFlow / EmployeeMaster / SysUser / OperatorBinding / ConfigHub / SysMenuSeedData /
watch-debug run-once / legacy ProcessRulesByTypeAsync / evaluator。
YY968XX před 2 měsíci
rodič
revize
4b3b9d6141

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

@@ -87,6 +87,22 @@ export interface S8WatchRuleConfigRow {
 	ruleType?: S8WatchRuleType | null;
 	sourceObjectType?: string | null;
 	paramsJson?: string | null;
+	// S8-SCHED-SCHEMA-1 + S8-SCHED-EXEC-1:调度参数 + 运行态字段
+	triggerCountRequired?: number | null;
+	recoverCountRequired?: number | null;
+	nextRunAt?: string | null;
+	lastRunAt?: string | null;
+	lastStatus?: string | null;
+	lastError?: string | null;
+	lastDurationMs?: number | null;
+	lastRunId?: string | null;
+	lockToken?: string | null;
+	lockedBy?: string | null;
+	lockUntil?: string | null;
+	runningStartedAt?: string | null;
+	consecutiveFailureCount?: number | null;
+	pausedUntil?: string | null;
+	pauseReason?: string | null;
 }
 
 export interface S8WatchRuleParamsPayload {
@@ -94,6 +110,12 @@ export interface S8WatchRuleParamsPayload {
 	enabled: boolean;
 }
 
+export interface S8WatchRuleSchedulePayload {
+	pollIntervalSeconds: number;
+	triggerCountRequired: number;
+	recoverCountRequired: number;
+}
+
 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),
@@ -136,6 +158,14 @@ export const s8ConfigApi = {
 			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),
+		updateSchedule: (id: number | string, body: S8WatchRuleSchedulePayload) =>
+			service.put<S8WatchRuleConfigRow>(`/api/aidop/s8/config/watch-rules/${id}/schedule`, body).then(unwrap),
+		runNow: (id: number | string) =>
+			service.post(`/api/aidop/s8/config/watch-rules/${id}/run-now`).then(unwrap),
+		pause: (id: number | string) =>
+			service.post(`/api/aidop/s8/config/watch-rules/${id}/pause`).then(unwrap),
+		resume: (id: number | string) =>
+			service.post(`/api/aidop/s8/config/watch-rules/${id}/resume`).then(unwrap),
 	},
 	dashboardCells: {
 		list: (params?: { tenantId?: number; factoryId?: number }) =>

+ 220 - 16
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts" name="aidopS8WatchRuleConfig">
-import { computed, onMounted, reactive, ref } from 'vue';
+import { computed, onActivated, onDeactivated, onMounted, onUnmounted, reactive, ref } from 'vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import {
@@ -84,6 +84,42 @@ const outOfRangeForm = reactive<OutOfRangeForm>({
 const enabledForm = ref(true);
 const rawParamsJson = ref<string>('');
 
+// S8-SCHED-FRONTEND-1:调度参数编辑表单。
+interface ScheduleForm {
+	pollIntervalSeconds: number;
+	triggerCountRequired: number;
+	recoverCountRequired: number;
+}
+const scheduleForm = reactive<ScheduleForm>({
+	pollIntervalSeconds: 300,
+	triggerCountRequired: 1,
+	recoverCountRequired: 1,
+});
+const POLL_INTERVAL_PRESETS = [
+	{ label: '1 分钟', value: 60 },
+	{ label: '5 分钟', value: 300 },
+	{ label: '15 分钟', value: 900 },
+	{ label: '30 分钟', value: 1800 },
+	{ label: '1 小时', value: 3600 },
+	{ label: '4 小时', value: 14400 },
+];
+
+// S8-SCHED-FRONTEND-1:30s 自动刷新;onMounted/onActivated 启动;onUnmounted/onDeactivated 清理。
+const REFRESH_INTERVAL_MS = 30000;
+let refreshTimer: ReturnType<typeof setInterval> | null = null;
+function startAutoRefresh() {
+	stopAutoRefresh();
+	refreshTimer = setInterval(() => {
+		if (document.visibilityState === 'visible' && !drawerOpen.value) loadRows();
+	}, REFRESH_INTERVAL_MS);
+}
+function stopAutoRefresh() {
+	if (refreshTimer != null) {
+		clearInterval(refreshTimer);
+		refreshTimer = null;
+	}
+}
+
 const ruleTypeOf = (row: S8WatchRuleConfigRow | null): S8WatchRuleType | '' =>
 	(row?.ruleType ?? '').toUpperCase();
 
@@ -153,6 +189,9 @@ function loadFormFromRow(row: S8WatchRuleConfigRow) {
 	resetForms();
 	enabledForm.value = !!row.enabled;
 	rawParamsJson.value = row.paramsJson ?? '';
+	scheduleForm.pollIntervalSeconds = clampInt(row.pollIntervalSeconds ?? 300, 60, 86400, 300);
+	scheduleForm.triggerCountRequired = clampInt(row.triggerCountRequired ?? 1, 1, 10, 1);
+	scheduleForm.recoverCountRequired = clampInt(row.recoverCountRequired ?? 1, 1, 10, 1);
 
 	if (!row.paramsJson) return;
 	let parsed: Record<string, any>;
@@ -268,11 +307,17 @@ async function save() {
 	if (!editingRow.value) return;
 	saving.value = true;
 	try {
-		const payload = {
+		// 调度参数走专用 schedule 端点;params_json / rule_type / expression 不会被触碰。
+		await s8ConfigApi.watchRules.updateSchedule(editingRow.value.id, {
+			pollIntervalSeconds: clampInt(scheduleForm.pollIntervalSeconds, 60, 86400, 300),
+			triggerCountRequired: clampInt(scheduleForm.triggerCountRequired, 1, 10, 1),
+			recoverCountRequired: clampInt(scheduleForm.recoverCountRequired, 1, 10, 1),
+		});
+		// params_json + enabled 仍走原 params 端点,保持模板化保存语义。
+		await s8ConfigApi.watchRules.updateParams(editingRow.value.id, {
 			paramsJson: buildPayloadParamsJson(),
 			enabled: enabledForm.value,
-		};
-		await s8ConfigApi.watchRules.updateParams(editingRow.value.id, payload);
+		});
 		ElMessage.success('保存成功');
 		closeDrawer();
 		await loadRows();
@@ -283,6 +328,77 @@ async function save() {
 	}
 }
 
+// S8-SCHED-FRONTEND-1:行操作。run-now / pause / resume 调用专用端点,操作完成后立即刷新列表。
+async function runNow(row: S8WatchRuleConfigRow) {
+	try {
+		await s8ConfigApi.watchRules.runNow(row.id);
+		ElMessage.success('已排队,最长 1 分钟内执行');
+		await loadRows();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? e?.message ?? '排队失败');
+	}
+}
+async function pauseRule(row: S8WatchRuleConfigRow) {
+	try {
+		await ElMessageBox.confirm(`确认暂停规则 ${row.ruleCode}?暂停后下一轮调度不再执行此规则。`, '提示', { type: 'warning' });
+	} catch { return; }
+	try {
+		await s8ConfigApi.watchRules.pause(row.id);
+		ElMessage.success('已暂停');
+		await loadRows();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? e?.message ?? '暂停失败');
+	}
+}
+async function resumeRule(row: S8WatchRuleConfigRow) {
+	try {
+		await s8ConfigApi.watchRules.resume(row.id);
+		ElMessage.success('已恢复,并将在下一轮调度中执行');
+		await loadRows();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? e?.message ?? '恢复失败');
+	}
+}
+
+function clampInt(raw: number | string | null | undefined, min: number, max: number, fallback: number) {
+	const n = Number(raw);
+	if (!Number.isFinite(n)) return fallback;
+	const i = Math.floor(n);
+	if (i < min) return min;
+	if (i > max) return max;
+	return i;
+}
+
+function isPaused(row: S8WatchRuleConfigRow) {
+	if (!row.pausedUntil) return false;
+	return new Date(row.pausedUntil).getTime() > Date.now();
+}
+function isRunning(row: S8WatchRuleConfigRow) {
+	if (!row.lockUntil) return false;
+	return new Date(row.lockUntil).getTime() > Date.now();
+}
+function pollIntervalLabel(seconds: number | null | undefined) {
+	if (!seconds || seconds <= 0) return '—';
+	const preset = POLL_INTERVAL_PRESETS.find((p) => p.value === seconds);
+	if (preset) return preset.label;
+	if (seconds < 60) return `${seconds} 秒`;
+	if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟`;
+	return `${Math.round(seconds / 3600)} 小时`;
+}
+function lastStatusTagType(status: string | null | undefined): 'success' | 'danger' | 'info' | '' {
+	switch ((status ?? '').toUpperCase()) {
+		case 'SUCCESS': return 'success';
+		case 'FAILED': return 'danger';
+		case 'SKIPPED': return 'info';
+		default: return '';
+	}
+}
+function nextRunDisplay(row: S8WatchRuleConfigRow) {
+	if (isPaused(row)) return '已暂停';
+	if (isRunning(row)) return '执行中';
+	return row.nextRunAt ?? '—';
+}
+
 async function toggleEnabled(row: S8WatchRuleConfigRow) {
 	const next = !row.enabled;
 	const action = next ? '启用' : '停用';
@@ -304,37 +420,89 @@ async function toggleEnabled(row: S8WatchRuleConfigRow) {
 onMounted(() => {
 	loadRows();
 	loadExceptionTypes();
+	startAutoRefresh();
 });
+onUnmounted(() => stopAutoRefresh());
+onActivated(() => startAutoRefresh());
+onDeactivated(() => stopAutoRefresh());
 </script>
 
 <template>
-	<AidopDemoShell title="监视规则配置" subtitle="S8 / 配置 / 监视规则(仅模板化参数编辑,不开放 SQL 编辑)">
+	<AidopDemoShell title="监视规则配置" subtitle="S8 / 配置 / 监视规则(参数模板化编辑 + 调度运行态;列表 30 秒自动刷新)">
+		<div class="rule-toolbar">
+			<el-button size="small" @click="loadRows" :loading="loading">手动刷新</el-button>
+			<span class="rule-toolbar__hint">每 30 秒自动刷新;编辑抽屉打开期间暂停刷新</span>
+		</div>
 		<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">
+			<el-table-column prop="ruleCode" label="规则编码" width="180" />
+			<el-table-column label="规则类型" width="100">
 				<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">
+			<el-table-column label="启用" width="80">
 				<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 label="轮询间隔" width="100">
+				<template #default="{ row }">{{ pollIntervalLabel(row.pollIntervalSeconds) }}</template>
+			</el-table-column>
+			<el-table-column label="触发阈值" width="110">
+				<template #default="{ row }">连续 {{ row.triggerCountRequired ?? 1 }} 次命中</template>
+			</el-table-column>
+			<el-table-column label="恢复阈值" width="120">
+				<template #default="{ row }">连续 {{ row.recoverCountRequired ?? 1 }} 次未命中</template>
+			</el-table-column>
+			<el-table-column label="上次执行" width="160">
+				<template #default="{ row }">
+					<el-tooltip v-if="row.lastRunAt" :content="String(row.lastRunAt)" placement="top">
+						<span>{{ row.lastRunAt }}</span>
+					</el-tooltip>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="下次执行" width="160">
+				<template #default="{ row }">
+					<el-tag v-if="isPaused(row)" type="warning" size="small">已暂停</el-tag>
+					<el-tag v-else-if="isRunning(row)" type="info" size="small">执行中</el-tag>
+					<span v-else>{{ nextRunDisplay(row) }}</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="上次状态" width="100">
+				<template #default="{ row }">
+					<el-tag v-if="row.lastStatus" :type="lastStatusTagType(row.lastStatus)" size="small">{{ row.lastStatus }}</el-tag>
+					<span v-else>—</span>
+				</template>
 			</el-table-column>
-			<el-table-column label="操作" width="180" fixed="right">
+			<el-table-column label="失败次数" width="90">
 				<template #default="{ row }">
-					<el-button size="small" type="primary" link @click="openEdit(row)">编辑参数</el-button>
+					<el-badge v-if="(row.consecutiveFailureCount ?? 0) > 0" :value="row.consecutiveFailureCount" type="danger" />
+					<span v-else>0</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="耗时" width="80">
+				<template #default="{ row }">{{ row.lastDurationMs != null ? `${row.lastDurationMs}ms` : '—' }}</template>
+			</el-table-column>
+			<el-table-column label="错误摘要" min-width="180" show-overflow-tooltip>
+				<template #default="{ row }">
+					<el-tooltip v-if="row.lastError" :content="String(row.lastError)" placement="top">
+						<span class="rule-error">{{ row.lastError }}</span>
+					</el-tooltip>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="操作" width="320" fixed="right">
+				<template #default="{ row }">
+					<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
+					<el-button size="small" type="success" link :disabled="!row.enabled || isPaused(row) || isRunning(row)" @click="runNow(row)">立即执行</el-button>
+					<el-button v-if="!isPaused(row)" size="small" type="warning" link @click="pauseRule(row)">暂停</el-button>
+					<el-button v-else size="small" type="success" link @click="resumeRule(row)">恢复</el-button>
+					<el-button v-if="!isPaused(row) && (row.consecutiveFailureCount ?? 0) > 0" size="small" type="success" link @click="resumeRule(row)">重置失败</el-button>
 					<el-button size="small" :type="row.enabled ? 'warning' : 'success'" link @click="toggleEnabled(row)">
 						{{ row.enabled ? '停用' : '启用' }}
 					</el-button>
@@ -448,6 +616,23 @@ onMounted(() => {
 					<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="轮询间隔(poll_interval_seconds,60–86400 秒)">
+						<el-select v-model="scheduleForm.pollIntervalSeconds" allow-create filterable placeholder="选择或输入秒数">
+							<el-option v-for="o in POLL_INTERVAL_PRESETS" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+					<el-form-item label="触发阈值(trigger_count_required,1–10)">
+						<el-input-number v-model="scheduleForm.triggerCountRequired" :min="1" :max="10" />
+						<span class="rule-hint">连续命中 N 次后才建单</span>
+					</el-form-item>
+					<el-form-item label="恢复阈值(recover_count_required,1–10)">
+						<el-input-number v-model="scheduleForm.recoverCountRequired" :min="1" :max="10" />
+						<span class="rule-hint">连续未命中 N 次后才标 recovered</span>
+					</el-form-item>
+				</el-form>
+
 				<el-divider content-position="left">启用</el-divider>
 				<el-form label-position="top" size="small">
 					<el-form-item label="启用状态">
@@ -494,4 +679,23 @@ onMounted(() => {
 	border-top: 1px solid var(--el-border-color-light);
 	text-align: right;
 }
+.rule-toolbar {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	margin-bottom: 8px;
+}
+.rule-toolbar__hint {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+.rule-hint {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+	margin-left: 8px;
+}
+.rule-error {
+	color: var(--el-color-danger);
+	font-size: 12px;
+}
 </style>

+ 133 - 0
lwb/journals/2026-04-28-S8-sched-frontend.md

@@ -0,0 +1,133 @@
+# S8-SCHED-FRONTEND-1 执行记录
+
+> 日期:2026-04-28
+> 范围:S8 监控规则前端调度操作 + 运行态展示
+
+## 任务状态
+
+- 任务名:S8-SCHED-FRONTEND-1
+- 开始:2026-04-28 13:55
+- 结束:2026-04-28 14:18
+- 耗时:约 23 分钟
+
+## 修改文件
+
+后端:
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs`
+  - 新增 `PUT /{id}/schedule` `POST /{id}/run-now` `POST /{id}/pause` `POST /{id}/resume` 4 端点
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs`
+  - 新增 `UpdateScheduleAsync` / `RunNowAsync` / `PauseAsync` / `ResumeAsync` 4 方法
+  - 复用 SqlSugar SetColumns 局部更新,不触碰 params_json / rule_type / expression / data_source_id
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleParamsPayload.cs`
+  - 新增 `S8WatchRuleSchedulePayload` 载荷(仅 poll/trigger/recover 三字段)
+
+前端:
+- `Web/src/views/aidop/s8/api/s8ConfigApi.ts`
+  - `S8WatchRuleConfigRow` 扩展 13 个运行态字段(triggerCountRequired/recoverCountRequired/nextRunAt/lastRunAt/lastStatus/lastError/lastDurationMs/lastRunId/lockToken/lockedBy/lockUntil/runningStartedAt/consecutiveFailureCount/pausedUntil/pauseReason)
+  - 新增 `S8WatchRuleSchedulePayload` 类型
+  - `s8ConfigApi.watchRules` 增加 `updateSchedule` / `runNow` / `pause` / `resume` 4 方法
+- `Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue`
+  - 列表新增 9 列(轮询间隔/触发阈值/恢复阈值/上次执行/下次执行/上次状态/失败次数/耗时/错误摘要)
+  - 行操作新增 立即执行 / 暂停 / 恢复 / 重置失败 4 个按钮
+  - 编辑抽屉新增「调度设置」区(poll_interval_seconds 预设下拉 + trigger/recover 数字输入)
+  - 30s 自动刷新(onMounted/onActivated 启动;onUnmounted/onDeactivated 清理;编辑抽屉打开期间或页面不可见时跳过刷新)
+  - save() 双端点:先 PUT /schedule(poll/trigger/recover),再 PUT /params(params_json/enabled)
+
+commit hash:(待提交)
+
+## 后端 build
+
+```
+dotnet build server/Plugins/Admin.NET.Plugin.AiDOP/Admin.NET.Plugin.AiDOP.csproj -f net10.0
+  → 0 Error, 64 Warning(pre-existing XML doc)
+```
+
+## 前端 build
+
+```
+npx vue-tsc --noEmit -p tsconfig.json
+  → 改动文件 0 error;其它 pre-existing 自动生成代码 / dragVerify 等不归本轮
+pnpm run build
+  → ✓ built in 44.41s
+```
+
+## API 验证
+
+`GET /api/aidop/s8/config/watch-rules?tenantId=1&factoryId=1` 返回字段(节选 rule 11):
+
+```json
+{
+  "id":"11", "ruleCode":"DEMO_ORDER_DIMENSION_OOR", "ruleType":"OUT_OF_RANGE",
+  "pollIntervalSeconds":300, "triggerCountRequired":1, "recoverCountRequired":1,
+  "nextRunAt":"2026-04-28 14:12:05", "lastRunAt":"2026-04-28 14:07:05", "lastStatus":"SUCCESS",
+  "lastError":null, "lastDurationMs":600, "consecutiveFailureCount":0,
+  "pausedUntil":null, "pauseReason":null, "lockToken":null, "lockedBy":null,
+  ...
+}
+```
+✅ 13 个运行态字段全部 camelCase 暴露。
+
+## Chrome MCP 验证
+
+| 验证项 | 结果 |
+|---|---|
+| 页面可打开(/aidop/s8/config/watch-rules)| ✅ 3 条 demo 规则全部展示 |
+| 列表运行态列 | ✅ 轮询间隔/触发阈值/恢复阈值/上次执行/下次执行/上次状态/失败次数/耗时/错误摘要 9 列均显示正确 |
+| poll 显示 | ✅ rule 10=1 分钟,rule 11/12=5 分钟 |
+| trigger / recover 显示 | ✅ "连续 1 次命中" / "连续 1 次未命中" |
+| last_run_at / next_run_at / last_status | ✅ 时间戳 + SUCCESS 绿色 tag |
+| 立即执行(rule 10)| ✅ next_run_at 置为 NOW,下个 tick 拾取,last_run_at 更新(14:09:04 → 14:11:04)|
+| 暂停(rule 11)| ✅ paused_until=9999-12-31,pause_reason=MANUAL_PAUSED;下个 tick 不被拾取(last_run_at 14:07:05 不变)|
+| 暂停 UI 反馈 | ✅ 下次执行列显示「已暂停」,按钮变成「恢复」,立即执行 disabled |
+| 执行中 UI 反馈 | ✅ rule 10/12 lease 持有期显示「执行中」,立即执行 disabled |
+| 恢复(rule 11)| ✅ paused_until→NULL,pause_reason→NULL,consecutive_failure_count=0,last_error=NULL,next_run_at=NOW |
+| 编辑调度参数(rule 10 trigger 1→2)| ✅ 保存后 trigger_count_required=2;rule_type/expression/params_json 数据不变(params_json 仅格式化重序列化,语义一致)|
+| Console error/warn | ✅ 0 |
+| Network 5xx | ✅ 0 |
+| 截图 | `/home/yy968/work/MeetingWorkflow/runs/s8-sched-frontend-20260428/01-watch-rules-runtime-cols.png` |
+
+## SQL 保存前后比对(rule 10 trigger 1→2)
+
+保存前:
+```
+rule_type: TIMEOUT
+expression: SELECT order_no AS related_object_code, ... FROM demo_test_order
+params_json: {"dueAtField":"due_at","statusField":"status","graceMinutes":0,...,"completedStates":["CLOSED","DONE","COMPLETED"],...}
+poll_interval_seconds: 60
+trigger_count_required: 1
+recover_count_required: 1
+```
+
+保存后:
+```
+rule_type: TIMEOUT             ✅ 不变
+expression: SELECT order_no... ✅ 不变
+params_json: {"dueAtField":"due_at","statusField":"status","completedStates":["CLOSED","DONE","COMPLETED"],...,"graceMinutes":0,...} ✅ 数据等价(仅字段顺序/空格归一化)
+poll_interval_seconds: 60      ✅ 不变(未编辑)
+trigger_count_required: 2      ✅ 按预期变化
+recover_count_required: 1      ✅ 不变(未编辑)
+```
+
+baseline:
+```
+SELECT COUNT(*) FROM ado_s8_exception_type WHERE tenant_id=0 AND factory_id=0 AND enabled=1 = 3
+```
+✅ 不变。
+
+cleanup 后已 `UPDATE rule 10 SET trigger_count_required=1`。
+
+## 未解决风险
+
+1. **params_json 数据等价但格式化不同**:save() 调用 updateSchedule 后再调用 updateParams,params_json 由前端从模板字段重新序列化(不是原样回写)。两次序列化的 JSON 顺序、空格不同,但内容等价。已验证 dueAtField/statusField/completedStates/objectCodeField/objectIdField/graceMinutes/exceptionTypeCode 全字段保留。如下游系统依赖原文格式(极少见),需要前端在保存调度时 skip params 重序列化;下一轮可考虑分离两个保存按钮。
+2. **运行态字段无独立 GET**:列表 API 返回所有字段;高频自动刷新 + 大量规则时数据传输较大。规模上来后可考虑 `/runtime` 轻量端点;本轮 dev 仅 3 条无需。
+3. **暂停哨兵 9999-12-31**:MySQL DATETIME 上限是 9999-12-31 23:59:59,正好可承载;但后续若迁 PostgreSQL 等需复核。
+4. **Chrome MCP 启动需用户预先开启 9222 端口**:本轮启动延迟一次后用户手动启动,验证恢复正常。流程上可在 reference_chrome_devtools_mcp.md 文档化。
+5. **Demo01 登录密码硬编码于本轮验证流**:仅用于本地 dev 验证,非提交内容。
+
+## 下一步建议
+
+- **S8-SCHED-MULTI-INSTANCE-1**:lease 模型已支持,需要在多实例环境复制并跑并发抢锁压测
+- **S8-DETECTION-STATE-RETENTION-1**:state 表按 last_seen_at 过期清理任务
+- **S8-SCHED-CLEANUP-LEGACY-PATH-1**:把 `ProcessRulesByTypeAsync` 改为 `ProcessSingleRuleAsync` 轻 wrapper
+- **S8-SCHED-RUNTIME-API-1**:考虑分离 `/runtime` 轻量端点,列表自动刷新只拉运行态
+- **S8-REGRESSION-FIXTURE-1**:注入 G01_TEST_* fixture seed;driver baseline 参数化

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

@@ -91,6 +91,41 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// S8-SCHED-FRONTEND-1:调度参数安全更新(仅 poll_interval_seconds / trigger_count_required / recover_count_required)。
+    /// 不动 params_json / rule_type / expression / data_source_id / scene_code。
+    /// </summary>
+    [HttpPut("{id:long}/schedule")]
+    public async Task<IActionResult> UpdateScheduleAsync(long id, [FromBody] S8WatchRuleSchedulePayload body)
+    {
+        try { return Ok(await _svc.UpdateScheduleAsync(id, body)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>S8-SCHED-FRONTEND-1:立即执行一次,next_run_at 置为 NOW,由下一 tick 拾取。</summary>
+    [HttpPost("{id:long}/run-now")]
+    public async Task<IActionResult> RunNowAsync(long id)
+    {
+        try { return Ok(await _svc.RunNowAsync(id)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>S8-SCHED-FRONTEND-1:手工暂停,paused_until 置为远未来哨兵 + pause_reason=MANUAL_PAUSED。</summary>
+    [HttpPost("{id:long}/pause")]
+    public async Task<IActionResult> PauseAsync(long id)
+    {
+        try { return Ok(await _svc.PauseAsync(id)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>S8-SCHED-FRONTEND-1:恢复,清 paused_until / pause_reason / last_error / consecutive_failure_count,next_run_at = NOW。</summary>
+    [HttpPost("{id:long}/resume")]
+    public async Task<IActionResult> ResumeAsync(long id)
+    {
+        try { return Ok(await _svc.ResumeAsync(id)); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
     /// <summary>
     /// 旧端点 deprecated 收口:写入响应 header 标记 + 结构化 warning 日志,便于运行期识别 legacy 调用。
     /// 不阻断调用,不改返回结构。

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

@@ -13,3 +13,15 @@ public sealed class S8WatchRuleParamsPayload
     /// <summary>启用 / 停用。</summary>
     public bool Enabled { get; set; }
 }
+
+/// <summary>
+/// S8-SCHED-FRONTEND-1:调度参数安全更新载荷。
+/// 仅承载 poll_interval_seconds / trigger_count_required / recover_count_required 三字段;
+/// 其它运行态字段(next_run_at / last_run_at / lock_*)不通过此接口修改。
+/// </summary>
+public sealed class S8WatchRuleSchedulePayload
+{
+    public int PollIntervalSeconds { get; set; } = 300;
+    public int TriggerCountRequired { get; set; } = 1;
+    public int RecoverCountRequired { get; set; } = 1;
+}

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

@@ -123,6 +123,106 @@ public class S8WatchRuleService : ITransient
         return new { id, success = true, message = "规则基础校验通过", pollIntervalSeconds = entity.PollIntervalSeconds };
     }
 
+    // S8-SCHED-FRONTEND-1:远未来手工暂停哨兵值(与 SqlSugar DateTime 兼容;前端按 paused_until > now 判定)。
+    private static readonly DateTime ManualPausedSentinel = new(9999, 12, 31, 23, 59, 59);
+
+    /// <summary>
+    /// S8-SCHED-FRONTEND-1:调度参数安全更新。仅修改 poll_interval_seconds / trigger_count_required /
+    /// recover_count_required;不动 params_json / expression / rule_type / scene_code / data_source_id。
+    /// </summary>
+    public async Task<AdoS8WatchRule> UpdateScheduleAsync(long id, S8WatchRuleSchedulePayload payload)
+    {
+        var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+
+        if (payload.PollIntervalSeconds < 60 || payload.PollIntervalSeconds > 86400)
+            throw new S8BizException("poll_interval_seconds 必须在 60–86400 之间");
+        if (payload.TriggerCountRequired < 1 || payload.TriggerCountRequired > 10)
+            throw new S8BizException("trigger_count_required 必须在 1–10 之间");
+        if (payload.RecoverCountRequired < 1 || payload.RecoverCountRequired > 10)
+            throw new S8BizException("recover_count_required 必须在 1–10 之间");
+
+        await _rep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                PollIntervalSeconds = payload.PollIntervalSeconds,
+                TriggerCountRequired = payload.TriggerCountRequired,
+                RecoverCountRequired = payload.RecoverCountRequired,
+                UpdatedAt = DateTime.Now
+            })
+            .Where(x => x.Id == id)
+            .ExecuteCommandAsync();
+        return await _rep.GetByIdAsync(id) ?? entity;
+    }
+
+    /// <summary>
+    /// S8-SCHED-FRONTEND-1:立即执行一次。把 next_run_at 置为 NOW,让下个 tick 拾取。
+    /// 不直接同步执行 evaluator;不阻塞请求;返回 200 + 提示。
+    /// </summary>
+    public async Task<object> RunNowAsync(long id)
+    {
+        var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        if (!entity.Enabled)
+            throw new S8BizException("规则未启用,不能立即执行");
+        var now = DateTime.Now;
+        if (entity.PausedUntil.HasValue && entity.PausedUntil.Value > now)
+            throw new S8BizException("规则已暂停,请先恢复");
+        if (entity.LockUntil.HasValue && entity.LockUntil.Value > now)
+            throw new S8BizException("规则正在执行中,请稍后再试");
+
+        await _rep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                NextRunAt = now,
+                UpdatedAt = now
+            })
+            .Where(x => x.Id == id)
+            .ExecuteCommandAsync();
+        return new { id, queued = true, message = "已排队,最长 1 分钟内执行" };
+    }
+
+    /// <summary>
+    /// S8-SCHED-FRONTEND-1:手工暂停。paused_until = 9999-12-31 哨兵 + pause_reason=MANUAL_PAUSED。
+    /// 不强杀正在执行的 lease;当前运行完成后下一轮自然不被拾取。
+    /// 不清 last_status / last_error。
+    /// </summary>
+    public async Task<object> PauseAsync(long id)
+    {
+        var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        await _rep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                PausedUntil = ManualPausedSentinel,
+                PauseReason = "MANUAL_PAUSED",
+                UpdatedAt = DateTime.Now
+            })
+            .Where(x => x.Id == id)
+            .ExecuteCommandAsync();
+        return new { id, paused = true, message = "已暂停" };
+    }
+
+    /// <summary>
+    /// S8-SCHED-FRONTEND-1:恢复。清 paused_until / pause_reason / last_error;归零 consecutive_failure_count;
+    /// next_run_at = NOW 让下个 tick 立即拾取。不改 enabled / params_json / rule_type。
+    /// </summary>
+    public async Task<object> ResumeAsync(long id)
+    {
+        var entity = await _rep.GetByIdAsync(id) ?? throw new S8BizException("记录不存在");
+        var now = DateTime.Now;
+        await _rep.Context.Updateable<AdoS8WatchRule>()
+            .SetColumns(x => new AdoS8WatchRule
+            {
+                PausedUntil = null,
+                PauseReason = null,
+                ConsecutiveFailureCount = 0,
+                LastError = null,
+                NextRunAt = now,
+                UpdatedAt = now
+            })
+            .Where(x => x.Id == id)
+            .ExecuteCommandAsync();
+        return new { id, resumed = true, message = "已恢复,并将在下一轮调度中执行" };
+    }
+
     private async Task ValidateAsync(AdoS8WatchRule body, long? id = null)
     {
         if (string.IsNullOrWhiteSpace(body.RuleCode) || string.IsNullOrWhiteSpace(body.SceneCode))