Junjie
2026-04-27 6fde9fe04f86a3376fc657f10b8aa32e4bc97436
feat: add auto tune console page
3个文件已添加
3个文件已修改
1107 ■■■■■ 已修改文件
src/main/java/com/zy/ai/controller/AutoTuneConsoleController.java 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/AutoTuneCoordinatorService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneCoordinatorServiceImpl.java 30 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260427_add_ai_auto_tune_console_menu.sql 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/auto_tune.html 817 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/service/AutoTuneCoordinatorServiceImplTest.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/AutoTuneConsoleController.java
New file
@@ -0,0 +1,151 @@
package com.zy.ai.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.ai.domain.autotune.AutoTuneApplyResult;
import com.zy.ai.entity.AiAutoTuneChange;
import com.zy.ai.entity.AiAutoTuneJob;
import com.zy.ai.service.AiAutoTuneChangeService;
import com.zy.ai.service.AiAutoTuneJobService;
import com.zy.ai.service.AutoTuneApplyService;
import com.zy.ai.service.AutoTuneCoordinatorService;
import com.zy.ai.service.AutoTuneSnapshotService;
import com.zy.common.web.BaseController;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/ai/autoTune")
@RequiredArgsConstructor
public class AutoTuneConsoleController extends BaseController {
    private static final int DEFAULT_JOB_LIMIT = 10;
    private static final int MAX_JOB_LIMIT = 50;
    private final AutoTuneSnapshotService autoTuneSnapshotService;
    private final AutoTuneCoordinatorService autoTuneCoordinatorService;
    private final AutoTuneApplyService autoTuneApplyService;
    private final AiAutoTuneJobService aiAutoTuneJobService;
    private final AiAutoTuneChangeService aiAutoTuneChangeService;
    @GetMapping("/snapshot/auth")
    @ManagerAuth(memo = "查看AI自动调参快照")
    public R snapshot() {
        return R.ok(autoTuneSnapshotService.buildSnapshot());
    }
    @GetMapping("/jobs/auth")
    @ManagerAuth(memo = "查看AI自动调参记录")
    public R jobs(@RequestParam(value = "limit", required = false) Integer limit) {
        int safeLimit = normalizeLimit(limit);
        List<AiAutoTuneJob> jobs = aiAutoTuneJobService.list(new QueryWrapper<AiAutoTuneJob>()
                .orderByDesc("start_time")
                .orderByDesc("id")
                .last("limit " + safeLimit));
        return R.ok(toJobSummaries(jobs));
    }
    @PostMapping("/triggerManual/auth")
    @ManagerAuth(memo = "手动触发AI自动调参Agent")
    public R triggerManual() {
        return R.ok(autoTuneCoordinatorService.runManualAutoTune());
    }
    @PostMapping("/triggerScheduler/auth")
    @ManagerAuth(memo = "按后台规则触发AI自动调参")
    public R triggerScheduler() {
        return R.ok(autoTuneCoordinatorService.runAutoTuneIfEligible());
    }
    @PostMapping("/rollback/auth")
    @ManagerAuth(memo = "回滚最近一次AI自动调参")
    public R rollback(@RequestParam(value = "reason", required = false) String reason) {
        AutoTuneApplyResult result = autoTuneApplyService.rollbackLastSuccessfulJob(reason);
        return R.ok(result);
    }
    private int normalizeLimit(Integer limit) {
        if (limit == null || limit <= 0) {
            return DEFAULT_JOB_LIMIT;
        }
        return Math.min(limit, MAX_JOB_LIMIT);
    }
    private List<Map<String, Object>> toJobSummaries(List<AiAutoTuneJob> jobs) {
        List<Map<String, Object>> result = new ArrayList<>();
        if (jobs == null || jobs.isEmpty()) {
            return result;
        }
        for (AiAutoTuneJob job : jobs) {
            result.add(toJobSummary(job));
        }
        return result;
    }
    private Map<String, Object> toJobSummary(AiAutoTuneJob job) {
        LinkedHashMap<String, Object> item = new LinkedHashMap<>();
        item.put("id", job.getId());
        item.put("triggerType", job.getTriggerType());
        item.put("status", job.getStatus());
        item.put("startTime", job.getStartTime());
        item.put("finishTime", job.getFinishTime());
        item.put("hasActiveTasks", job.getHasActiveTasks());
        item.put("summary", job.getSummary());
        item.put("reasoningDigest", job.getReasoningDigest());
        item.put("snapshotDigest", job.getSnapshotDigest());
        item.put("intervalBefore", job.getIntervalBefore());
        item.put("intervalAfter", job.getIntervalAfter());
        item.put("successCount", job.getSuccessCount());
        item.put("rejectCount", job.getRejectCount());
        item.put("errorMessage", job.getErrorMessage());
        item.put("llmCallCount", job.getLlmCallCount());
        item.put("promptTokens", job.getPromptTokens());
        item.put("completionTokens", job.getCompletionTokens());
        item.put("totalTokens", job.getTotalTokens());
        item.put("changes", listChangeSummaries(job.getId()));
        return item;
    }
    private List<Map<String, Object>> listChangeSummaries(Long jobId) {
        List<Map<String, Object>> result = new ArrayList<>();
        if (jobId == null) {
            return result;
        }
        List<AiAutoTuneChange> changes = aiAutoTuneChangeService.list(new QueryWrapper<AiAutoTuneChange>()
                .eq("job_id", jobId)
                .orderByAsc("id"));
        if (changes == null || changes.isEmpty()) {
            return result;
        }
        for (AiAutoTuneChange change : changes) {
            result.add(toChangeSummary(change));
        }
        return result;
    }
    private Map<String, Object> toChangeSummary(AiAutoTuneChange change) {
        LinkedHashMap<String, Object> item = new LinkedHashMap<>();
        item.put("id", change.getId());
        item.put("targetType", change.getTargetType());
        item.put("targetId", change.getTargetId());
        item.put("targetKey", change.getTargetKey());
        item.put("oldValue", change.getOldValue());
        item.put("requestedValue", change.getRequestedValue());
        item.put("appliedValue", change.getAppliedValue());
        item.put("resultStatus", change.getResultStatus());
        item.put("rejectReason", change.getRejectReason());
        item.put("cooldownExpireTime", change.getCooldownExpireTime());
        item.put("createTime", change.getCreateTime());
        return item;
    }
}
src/main/java/com/zy/ai/service/AutoTuneCoordinatorService.java
@@ -8,6 +8,8 @@
    AutoTuneCoordinatorResult runAutoTuneIfEligible();
    AutoTuneCoordinatorResult runManualAutoTune();
    @Data
    class AutoTuneCoordinatorResult implements Serializable {
        private static final long serialVersionUID = 1L;
src/main/java/com/zy/ai/service/impl/AutoTuneCoordinatorServiceImpl.java
@@ -75,6 +75,15 @@
            return AutoTuneCoordinatorResult.skipped("interval_not_reached");
        }
        return runAgentWithLock(AutoTuneTriggerType.AUTO.getCode(), intervalMinutes, true);
    }
    @Override
    public AutoTuneCoordinatorResult runManualAutoTune() {
        return runAgentWithLock(AutoTuneTriggerType.MANUAL.getCode(), null, false);
    }
    private AutoTuneCoordinatorResult runAgentWithLock(String triggerType, Integer intervalMinutes, boolean writeGuard) {
        String lockKey = RedisKeyType.AI_AUTO_TUNE_RUNNING_LOCK.key;
        String lockToken = UUID.randomUUID().toString();
        if (!redisUtil.trySetStringIfAbsent(lockKey, lockToken, RUNNING_LOCK_SECONDS)) {
@@ -83,13 +92,15 @@
        AutoTuneAgentService.AutoTuneAgentResult agentResult = null;
        try {
            safeMarkLastTriggerGuard(intervalMinutes);
            agentResult = autoTuneAgentService.runAutoTune(AutoTuneTriggerType.AUTO.getCode());
            if (writeGuard && intervalMinutes != null) {
                safeMarkLastTriggerGuard(intervalMinutes);
            }
            agentResult = autoTuneAgentService.runAutoTune(triggerType);
            safeWriteOperateLog(agentResult);
            return AutoTuneCoordinatorResult.triggered(agentResult);
        } catch (Exception exception) {
            log.error("Auto tune coordinator failed to run agent", exception);
            agentResult = failedAgentResult(exception);
            agentResult = failedAgentResult(triggerType, exception);
            safeWriteOperateLog(agentResult);
            return AutoTuneCoordinatorResult.triggered(agentResult);
        } finally {
@@ -173,10 +184,10 @@
                String.valueOf(System.currentTimeMillis()), expireSeconds);
    }
    private AutoTuneAgentService.AutoTuneAgentResult failedAgentResult(Exception exception) {
    private AutoTuneAgentService.AutoTuneAgentResult failedAgentResult(String triggerType, Exception exception) {
        AutoTuneAgentService.AutoTuneAgentResult result = new AutoTuneAgentService.AutoTuneAgentResult();
        result.setSuccess(false);
        result.setTriggerType(AutoTuneTriggerType.AUTO.getCode());
        result.setTriggerType(triggerType);
        result.setSummary("自动调参后台任务执行异常: " + exception.getMessage());
        result.setToolCallCount(0);
        result.setLlmCallCount(0);
@@ -200,7 +211,7 @@
            return;
        }
        OperateLog operateLog = new OperateLog();
        operateLog.setAction("ai_auto_tune_background_scheduler");
        operateLog.setAction(resolveOperateLogAction(agentResult));
        operateLog.setUserId(SYSTEM_USER_ID);
        operateLog.setIp("system");
        operateLog.setRequest(JSON.toJSONString(buildRequestSummary(agentResult)));
@@ -209,6 +220,13 @@
        operateLogService.save(operateLog);
    }
    private String resolveOperateLogAction(AutoTuneAgentService.AutoTuneAgentResult agentResult) {
        if (agentResult != null && AutoTuneTriggerType.MANUAL.getCode().equals(agentResult.getTriggerType())) {
            return "ai_auto_tune_manual_trigger";
        }
        return "ai_auto_tune_background_scheduler";
    }
    private Map<String, Object> buildRequestSummary(AutoTuneAgentService.AutoTuneAgentResult agentResult) {
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("trigger", agentResult.getTriggerType());
src/main/resources/sql/20260427_add_ai_auto_tune_console_menu.sql
New file
@@ -0,0 +1,72 @@
-- AI自动调参控制台菜单增量脚本
-- 执行后请在“角色授权”里给对应角色勾选 AI管理 -> AI自动调参控制台。
SET @ai_manage_id := COALESCE(
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'aiManage' AND level = 1
    ORDER BY id
    LIMIT 1
  ),
  (
    SELECT id
    FROM sys_resource
    WHERE name = 'AI管理' AND level = 1
    ORDER BY id
    LIMIT 1
  )
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'ai/auto_tune.html', 'AI自动调参控制台', @ai_manage_id, 2, 4, 1
FROM dual
WHERE @ai_manage_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'ai/auto_tune.html' AND level = 2
  );
UPDATE sys_resource
SET name = 'AI自动调参控制台',
    resource_id = @ai_manage_id,
    level = 2,
    sort = 4,
    status = 1
WHERE code = 'ai/auto_tune.html' AND level = 2;
SET @ai_auto_tune_id := (
  SELECT id
  FROM sys_resource
  WHERE code = 'ai/auto_tune.html' AND level = 2
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'ai/auto_tune.html#view', '查看', @ai_auto_tune_id, 3, 1, 1
FROM dual
WHERE @ai_auto_tune_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'ai/auto_tune.html#view' AND level = 3
  );
UPDATE sys_resource
SET name = '查看',
    resource_id = @ai_auto_tune_id,
    level = 3,
    sort = 1,
    status = 1
WHERE code = 'ai/auto_tune.html#view' AND level = 3;
SELECT id, code, name, resource_id, level, sort, status
FROM sys_resource
WHERE code IN (
  'aiManage',
  'ai/auto_tune.html',
  'ai/auto_tune.html#view'
)
ORDER BY level, sort, id;
src/main/webapp/views/ai/auto_tune.html
New file
@@ -0,0 +1,817 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AI自动调参</title>
  <link rel="stylesheet" href="../../static/vue/element/element.css" />
  <style>
    body {
      margin: 0;
      font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
      background:
        radial-gradient(900px 460px at 4% -8%, rgba(36, 113, 92, 0.16), transparent 52%),
        radial-gradient(820px 420px at 106% 0%, rgba(20, 82, 128, 0.14), transparent 54%),
        linear-gradient(180deg, #f4f8fb 0%, #eef4f8 100%);
      color: #223046;
    }
    .console-page {
      max-width: 1680px;
      margin: 16px auto;
      padding: 0 14px 22px;
    }
    .hero {
      border-radius: 18px;
      color: #fff;
      padding: 16px;
      background:
        linear-gradient(135deg, rgba(14, 76, 82, 0.96), rgba(31, 115, 108, 0.92) 48%, rgba(44, 130, 86, 0.94)),
        radial-gradient(460px 180px at 80% 0%, rgba(255, 255, 255, 0.24), transparent 60%);
      box-shadow: 0 14px 34px rgba(26, 76, 91, 0.22);
    }
    .hero-top {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      flex-wrap: wrap;
    }
    .hero-title {
      display: flex;
      align-items: center;
      gap: 12px;
      min-width: 280px;
    }
    .hero-title .main {
      font-size: 18px;
      font-weight: 700;
      letter-spacing: 0.2px;
    }
    .hero-title .sub {
      margin-top: 4px;
      font-size: 12px;
      opacity: 0.9;
    }
    .hero-actions {
      display: flex;
      align-items: center;
      justify-content: flex-end;
      gap: 8px;
      flex-wrap: wrap;
    }
    .summary-grid {
      margin-top: 12px;
      display: grid;
      grid-template-columns: repeat(6, minmax(0, 1fr));
      gap: 10px;
    }
    .summary-card {
      min-height: 68px;
      border-radius: 13px;
      padding: 10px 12px;
      background: rgba(255, 255, 255, 0.14);
      border: 1px solid rgba(255, 255, 255, 0.24);
      backdrop-filter: blur(4px);
    }
    .summary-card .k {
      font-size: 12px;
      opacity: 0.86;
    }
    .summary-card .v {
      margin-top: 6px;
      font-size: 24px;
      font-weight: 750;
      line-height: 1.1;
    }
    .summary-card .hint {
      margin-top: 4px;
      font-size: 11px;
      opacity: 0.76;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .layout {
      margin-top: 12px;
      display: grid;
      grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.4fr);
      gap: 12px;
    }
    .panel {
      border-radius: 16px;
      border: 1px solid #dfe8f1;
      background: rgba(255, 255, 255, 0.88);
      box-shadow: 0 10px 28px rgba(31, 62, 92, 0.1);
      overflow: hidden;
    }
    .panel-head {
      padding: 12px 14px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      border-bottom: 1px solid #edf2f7;
      background:
        linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
    }
    .panel-title {
      font-weight: 700;
      color: #223046;
    }
    .panel-tip {
      margin-top: 2px;
      color: #718299;
      font-size: 12px;
    }
    .panel-body {
      padding: 12px 14px 14px;
    }
    .param-grid {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 8px;
    }
    .param-card {
      border-radius: 12px;
      border: 1px solid #e4ebf2;
      background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
      padding: 10px;
      min-height: 64px;
    }
    .param-card .k {
      color: #72849a;
      font-size: 12px;
    }
    .param-card .v {
      margin-top: 6px;
      color: #1f3c4d;
      font-size: 22px;
      font-weight: 720;
    }
    .map-list {
      margin-top: 10px;
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 10px;
    }
    .map-box {
      min-height: 112px;
      border-radius: 12px;
      border: 1px solid #e6edf4;
      background: #fbfdff;
      padding: 10px;
    }
    .map-title {
      font-size: 12px;
      color: #61748a;
      margin-bottom: 8px;
      font-weight: 700;
    }
    .pill-row {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      max-height: 120px;
      overflow: auto;
    }
    .kv-pill {
      border-radius: 999px;
      border: 1px solid #dfe8f2;
      background: #fff;
      color: #42566f;
      font-size: 12px;
      padding: 4px 8px;
      white-space: nowrap;
    }
    .split-grid {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
      gap: 12px;
      margin-top: 12px;
    }
    .raw-box {
      border-radius: 12px;
      background: #10202a;
      color: #d8f5e8;
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      font-size: 12px;
      line-height: 1.55;
      padding: 12px;
      max-height: 66vh;
      overflow: auto;
      white-space: pre-wrap;
      word-break: break-word;
    }
    .agent-result {
      border-radius: 13px;
      border: 1px solid #dfe8f2;
      background:
        radial-gradient(460px 180px at 100% 0, rgba(38, 130, 97, 0.08), transparent 62%),
        #fbfdff;
      padding: 10px 12px;
      min-height: 120px;
    }
    .result-line {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      margin-bottom: 8px;
    }
    .summary-text {
      color: #32465f;
      line-height: 1.65;
      white-space: pre-wrap;
      word-break: break-word;
    }
    .small-muted {
      color: #7a8aa0;
      font-size: 12px;
    }
    .toolbar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      flex-wrap: wrap;
      margin-bottom: 10px;
    }
    .job-expand {
      padding: 8px 12px 12px;
      background: #fbfdff;
      border-radius: 10px;
      border: 1px solid #e7edf4;
    }
    .json-dialog-body {
      margin: -8px -6px 0;
    }
    .mono {
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
    }
    @media (max-width: 1360px) {
      .summary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
      .layout { grid-template-columns: 1fr; }
    }
    @media (max-width: 760px) {
      .summary-grid, .param-grid, .map-list, .split-grid { grid-template-columns: 1fr; }
      .hero-actions { justify-content: flex-start; }
    }
  </style>
</head>
<body>
<div id="app" class="console-page">
  <div class="hero">
    <div class="hero-top">
      <div class="hero-title">
        <div v-html="headerIcon" style="display:flex;"></div>
        <div>
          <div class="main">AI自动调参控制台</div>
          <div class="sub">手动触发 Agent、查看实时快照、审计调参动作和回滚最近成功调参</div>
        </div>
      </div>
      <div class="hero-actions">
        <el-button type="primary" size="mini" :loading="snapshotLoading" @click="refreshAll">刷新数据</el-button>
        <el-button type="success" size="mini" :loading="agentLoading" @click="triggerManual">手动触发Agent</el-button>
        <el-button size="mini" :loading="schedulerLoading" @click="triggerScheduler">按后台规则触发</el-button>
        <el-button type="danger" plain size="mini" :loading="rollbackLoading" @click="confirmRollback">回滚最近成功调参</el-button>
      </div>
    </div>
    <div class="summary-grid">
      <div class="summary-card">
        <div class="k">活动任务</div>
        <div class="v">{{ taskSnapshot.activeTaskCount || 0 }}</div>
        <div class="hint">未完成任务总数</div>
      </div>
      <div class="summary-card">
        <div class="k">出库站点上限</div>
        <div class="v">{{ valueOrDash(parameterSnapshot.conveyorStationTaskLimit) }}</div>
        <div class="hint">sys_config.conveyorStationTaskLimit</div>
      </div>
      <div class="summary-card">
        <div class="k">堆垛机批次上限</div>
        <div class="v">{{ valueOrDash(parameterSnapshot.crnOutBatchRunningLimit) }}</div>
        <div class="hint">sys_config.crnOutBatchRunningLimit</div>
      </div>
      <div class="summary-card">
        <div class="k">分析间隔</div>
        <div class="v">{{ valueOrDash(parameterSnapshot.aiAutoTuneIntervalMinutes) }}</div>
        <div class="hint">分钟,可由 Agent 调整</div>
      </div>
      <div class="summary-card">
        <div class="k">运行站点</div>
        <div class="v">{{ stationBusyCount }} / {{ stationRuntime.length }}</div>
        <div class="hint">autoing/loading/taskNo</div>
      </div>
      <div class="summary-card">
        <div class="k">最近调参</div>
        <div class="v">{{ jobs.length }}</div>
        <div class="hint">当前列表记录数</div>
      </div>
    </div>
  </div>
  <div class="layout">
    <div>
      <div class="panel">
        <div class="panel-head">
          <div>
            <div class="panel-title">当前参数</div>
            <div class="panel-tip">展示允许 Agent 动态修改的参数当前值</div>
          </div>
          <el-button size="mini" plain @click="openJsonDialog('当前参数', parameterSnapshot)">JSON</el-button>
        </div>
        <div class="panel-body">
          <div class="param-grid">
            <div class="param-card">
              <div class="k">conveyorStationTaskLimit</div>
              <div class="v">{{ valueOrDash(parameterSnapshot.conveyorStationTaskLimit) }}</div>
            </div>
            <div class="param-card">
              <div class="k">crnOutBatchRunningLimit</div>
              <div class="v">{{ valueOrDash(parameterSnapshot.crnOutBatchRunningLimit) }}</div>
            </div>
            <div class="param-card">
              <div class="k">aiAutoTuneIntervalMinutes</div>
              <div class="v">{{ valueOrDash(parameterSnapshot.aiAutoTuneIntervalMinutes) }}</div>
            </div>
          </div>
          <div class="map-list">
            <div class="map-box">
              <div class="map-title">出库站点 outTaskLimit</div>
              <div class="pill-row">
                <span class="kv-pill" v-for="item in mapEntries(parameterSnapshot.stationOutTaskLimits)" :key="'s_' + item.key">{{ item.key }}: {{ item.value }}</span>
                <span class="small-muted" v-if="mapEntries(parameterSnapshot.stationOutTaskLimits).length === 0">暂无数据</span>
              </div>
            </div>
            <div class="map-box">
              <div class="map-title">单工位堆垛机 maxOut/maxIn</div>
              <div class="pill-row">
                <span class="kv-pill" v-for="item in combineTaskLimitEntries(parameterSnapshot.crnMaxOutTask, parameterSnapshot.crnMaxInTask)" :key="'c_' + item.key">{{ item.key }}: 出{{ item.out }} / 入{{ item.in }}</span>
                <span class="small-muted" v-if="combineTaskLimitEntries(parameterSnapshot.crnMaxOutTask, parameterSnapshot.crnMaxInTask).length === 0">暂无数据</span>
              </div>
            </div>
            <div class="map-box">
              <div class="map-title">双工位堆垛机 maxOut/maxIn</div>
              <div class="pill-row">
                <span class="kv-pill" v-for="item in combineTaskLimitEntries(parameterSnapshot.dualCrnMaxOutTask, parameterSnapshot.dualCrnMaxInTask)" :key="'d_' + item.key">{{ item.key }}: 出{{ item.out }} / 入{{ item.in }}</span>
                <span class="small-muted" v-if="combineTaskLimitEntries(parameterSnapshot.dualCrnMaxOutTask, parameterSnapshot.dualCrnMaxInTask).length === 0">暂无数据</span>
              </div>
            </div>
            <div class="map-box">
              <div class="map-title">任务分布</div>
              <div class="pill-row">
                <span class="kv-pill" v-for="item in mapEntries(taskSnapshot.byTargetStation)" :key="'t_' + item.key">站点{{ item.key }}: {{ item.value }}</span>
                <span class="kv-pill" v-for="item in mapEntries(taskSnapshot.byBatch)" :key="'b_' + item.key">批次{{ item.key }}: {{ item.value }}</span>
                <span class="small-muted" v-if="mapEntries(taskSnapshot.byTargetStation).length === 0 && mapEntries(taskSnapshot.byBatch).length === 0">暂无活动任务</span>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div class="panel" style="margin-top:12px;">
        <div class="panel-head">
          <div>
            <div class="panel-title">Agent执行结果</div>
            <div class="panel-tip">手动触发或后台规则触发后的最近一次返回</div>
          </div>
          <el-tag size="mini" :type="agentResultType">{{ agentResultLabel }}</el-tag>
        </div>
        <div class="panel-body">
          <div class="agent-result">
            <div class="result-line">
              <el-tag size="mini">触发: {{ (agentResult && agentResult.triggerType) || '-' }}</el-tag>
              <el-tag size="mini">工具: {{ valueOrDash(agentResult && agentResult.toolCallCount) }}</el-tag>
              <el-tag size="mini">LLM: {{ valueOrDash(agentResult && agentResult.llmCallCount) }}</el-tag>
              <el-tag size="mini">Tokens: {{ valueOrDash(agentResult && agentResult.totalTokens) }}</el-tag>
            </div>
            <div class="summary-text">{{ (agentResult && agentResult.summary) || '尚未在本页面触发 Agent。' }}</div>
          </div>
        </div>
      </div>
    </div>
    <div>
      <div class="panel">
        <div class="panel-head">
          <div>
            <div class="panel-title">站点运行态</div>
            <div class="panel-tip">只展示 Agent 可依据的 autoing、loading、taskNo 与站点模式</div>
          </div>
          <el-button size="mini" plain @click="openJsonDialog('站点运行态', stationRuntime)">JSON</el-button>
        </div>
        <div class="panel-body">
          <el-table :data="stationRuntime" border stripe height="260" size="mini" v-loading="snapshotLoading"
                    :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
            <el-table-column prop="stationId" label="站点" width="90"></el-table-column>
            <el-table-column prop="ioMode" label="模式" width="90"></el-table-column>
            <el-table-column prop="autoing" label="autoing" width="90"></el-table-column>
            <el-table-column prop="loading" label="loading" width="90"></el-table-column>
            <el-table-column prop="taskNo" label="taskNo" min-width="160"></el-table-column>
          </el-table>
        </div>
      </div>
      <div class="split-grid">
        <div class="panel">
          <div class="panel-head">
            <div>
              <div class="panel-title">通道/环线缓存</div>
              <div class="panel-tip">用于判断站点方向与缓存容量</div>
            </div>
            <el-button size="mini" plain @click="openJsonDialog('拓扑缓存', topology)">JSON</el-button>
          </div>
          <div class="panel-body">
            <el-table :data="topology" border stripe height="300" size="mini" v-loading="snapshotLoading"
                      :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
              <el-table-column prop="targetStationId" label="目标站" width="80"></el-table-column>
              <el-table-column prop="bufferCapacity" label="容量" width="70"></el-table-column>
              <el-table-column prop="occupiedCount" label="占用" width="70"></el-table-column>
              <el-table-column prop="freeCount" label="空余" width="70"></el-table-column>
              <el-table-column prop="direction" label="方向" min-width="120"></el-table-column>
            </el-table>
          </div>
        </div>
        <div class="panel">
          <div class="panel-head">
            <div>
              <div class="panel-title">输送线负载</div>
              <div class="panel-tip">来自当前节拍/环线负载快照</div>
            </div>
            <el-button size="mini" plain @click="openJsonDialog('输送线负载', cycleLoad)">JSON</el-button>
          </div>
          <div class="panel-body">
            <div class="raw-box" style="height:276px;">{{ prettyJson(cycleLoad) }}</div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <div class="panel" style="margin-top:12px;">
    <div class="panel-head">
      <div>
        <div class="panel-title">调参审计记录</div>
        <div class="panel-tip">最近自动/手动/回滚任务,以及每条参数变更结果</div>
      </div>
      <div class="toolbar" style="margin-bottom:0;">
        <el-select v-model="jobLimit" size="mini" style="width:100px;" @change="loadJobs">
          <el-option :value="10" label="10条"></el-option>
          <el-option :value="20" label="20条"></el-option>
          <el-option :value="50" label="50条"></el-option>
        </el-select>
        <el-button size="mini" :loading="jobsLoading" @click="loadJobs">刷新记录</el-button>
      </div>
    </div>
    <div class="panel-body">
      <el-table :data="jobs" border stripe size="mini" v-loading="jobsLoading"
                :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
        <el-table-column type="expand">
          <template slot-scope="scope">
            <div class="job-expand">
              <div class="small-muted" style="margin-bottom:8px;">摘要</div>
              <div class="summary-text" style="margin-bottom:10px;">{{ scope.row.summary || '-' }}</div>
              <el-table :data="scope.row.changes || []" border size="mini"
                        :header-cell-style="{background:'#fbfcfe', color:'#55677f'}">
                <el-table-column prop="targetType" label="类型" width="95"></el-table-column>
                <el-table-column prop="targetId" label="目标" width="90"></el-table-column>
                <el-table-column prop="targetKey" label="参数" min-width="160"></el-table-column>
                <el-table-column prop="oldValue" label="原值" width="80"></el-table-column>
                <el-table-column prop="requestedValue" label="请求值" width="80"></el-table-column>
                <el-table-column prop="appliedValue" label="生效值" width="80"></el-table-column>
                <el-table-column prop="resultStatus" label="结果" width="90"></el-table-column>
                <el-table-column prop="rejectReason" label="拒绝原因" min-width="220"></el-table-column>
              </el-table>
            </div>
          </template>
        </el-table-column>
        <el-table-column prop="id" label="ID" width="80"></el-table-column>
        <el-table-column prop="triggerType" label="触发" width="90"></el-table-column>
        <el-table-column label="状态" width="95">
          <template slot-scope="scope">
            <el-tag size="mini" :type="statusType(scope.row.status)">{{ scope.row.status || '-' }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="startTime" label="开始时间" min-width="150" :formatter="dateFormatter"></el-table-column>
        <el-table-column prop="finishTime" label="结束时间" min-width="150" :formatter="dateFormatter"></el-table-column>
        <el-table-column prop="successCount" label="成功" width="70"></el-table-column>
        <el-table-column prop="rejectCount" label="拒绝" width="70"></el-table-column>
        <el-table-column prop="llmCallCount" label="LLM" width="70"></el-table-column>
        <el-table-column prop="totalTokens" label="Tokens" width="90"></el-table-column>
        <el-table-column prop="errorMessage" label="错误" min-width="180"></el-table-column>
      </el-table>
    </div>
  </div>
  <el-dialog :title="jsonDialogTitle" :visible.sync="jsonDialogVisible" width="78%" :close-on-click-modal="false">
    <div class="json-dialog-body">
      <div class="raw-box">{{ prettyJson(jsonDialogData) }}</div>
    </div>
  </el-dialog>
</div>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script>
  new Vue({
    el: '#app',
    data: function() {
      return {
        headerIcon: getAiIconHtml(36, 36),
        snapshotLoading: false,
        jobsLoading: false,
        agentLoading: false,
        schedulerLoading: false,
        rollbackLoading: false,
        snapshot: {},
        jobs: [],
        jobLimit: 10,
        agentResult: null,
        jsonDialogVisible: false,
        jsonDialogTitle: '',
        jsonDialogData: null
      };
    },
    computed: {
      taskSnapshot: function() {
        return this.snapshot.taskSnapshot || {};
      },
      parameterSnapshot: function() {
        return this.snapshot.currentParameterSnapshot || {};
      },
      stationRuntime: function() {
        return this.snapshot.stationRuntimeSnapshot || [];
      },
      topology: function() {
        return this.snapshot.flowTopologySnapshot || [];
      },
      cycleLoad: function() {
        return this.snapshot.cycleLoadSnapshot || {};
      },
      stationBusyCount: function() {
        var count = 0;
        for (var stationIndex = 0; stationIndex < this.stationRuntime.length; stationIndex++) {
          var station = this.stationRuntime[stationIndex] || {};
          if (station.autoing || station.loading || station.taskNo) {
            count++;
          }
        }
        return count;
      },
      agentResultLabel: function() {
        if (!this.agentResult) {
          return '未触发';
        }
        return this.agentResult.success === true ? '成功' : '失败/无调整';
      },
      agentResultType: function() {
        if (!this.agentResult) {
          return 'info';
        }
        return this.agentResult.success === true ? 'success' : 'warning';
      }
    },
    methods: {
      authHeaders: function() {
        return { 'token': localStorage.getItem('token') };
      },
      requestJson: function(url, options) {
        var requestOptions = options || {};
        requestOptions.headers = requestOptions.headers || this.authHeaders();
        return fetch(url, requestOptions).then(function(response) {
          return response.json();
        });
      },
      refreshAll: function() {
        this.loadSnapshot();
        this.loadJobs();
      },
      loadSnapshot: function() {
        var self = this;
        self.snapshotLoading = true;
        self.requestJson(baseUrl + '/ai/autoTune/snapshot/auth')
          .then(function(res) {
            self.snapshotLoading = false;
            if (res && res.code === 200) {
              self.snapshot = res.data || {};
              return;
            }
            self.$message.error((res && res.msg) ? res.msg : '加载快照失败');
          })
          .catch(function() {
            self.snapshotLoading = false;
            self.$message.error('加载快照失败');
          });
      },
      loadJobs: function() {
        var self = this;
        self.jobsLoading = true;
        self.requestJson(baseUrl + '/ai/autoTune/jobs/auth?limit=' + encodeURIComponent(self.jobLimit))
          .then(function(res) {
            self.jobsLoading = false;
            if (res && res.code === 200) {
              self.jobs = Array.isArray(res.data) ? res.data : [];
              return;
            }
            self.$message.error((res && res.msg) ? res.msg : '加载调参记录失败');
          })
          .catch(function() {
            self.jobsLoading = false;
            self.$message.error('加载调参记录失败');
          });
      },
      triggerManual: function() {
        var self = this;
        self.$confirm('手动触发会立即调用 Agent,并可能通过 MCP 执行 dry-run 与实际调参。是否继续?', '手动触发Agent', {
          type: 'warning'
        }).then(function() {
          self.agentLoading = true;
          return self.requestJson(baseUrl + '/ai/autoTune/triggerManual/auth', {
            method: 'POST',
            headers: self.authHeaders()
          });
        }).then(function(res) {
          self.agentLoading = false;
          if (!res) {
            return;
          }
          if (res.code === 200) {
            var manualResult = self.normalizeCoordinatorResult(res.data || {}, 'manual');
            self.agentResult = manualResult;
            if ((res.data || {}).triggered === true) {
              self.$message.success('Agent执行结束');
            } else {
              self.$message.warning('未触发Agent: ' + ((res.data || {}).reason || '-'));
            }
            self.refreshAll();
            return;
          }
          self.$message.error(res.msg || '手动触发失败');
        }).catch(function(error) {
          self.agentLoading = false;
          if (error !== 'cancel') {
            self.$message.error('手动触发失败');
          }
        });
      },
      triggerScheduler: function() {
        var self = this;
        self.schedulerLoading = true;
        self.requestJson(baseUrl + '/ai/autoTune/triggerScheduler/auth', {
          method: 'POST',
          headers: self.authHeaders()
        }).then(function(res) {
          self.schedulerLoading = false;
          if (res && res.code === 200) {
            self.agentResult = self.normalizeCoordinatorResult(res.data || {}, 'auto');
            self.$alert(self.prettyJson(res.data || {}), '后台规则触发结果', { confirmButtonText: '确定' });
            self.refreshAll();
            return;
          }
          self.$message.error((res && res.msg) ? res.msg : '后台规则触发失败');
        }).catch(function() {
          self.schedulerLoading = false;
          self.$message.error('后台规则触发失败');
        });
      },
      confirmRollback: function() {
        var self = this;
        self.$prompt('请输入回滚原因。建议写明来自快照或审计记录的异常证据。', '回滚最近成功调参', {
          confirmButtonText: '回滚',
          cancelButtonText: '取消',
          inputPlaceholder: '例如:手动确认最近调参造成某出库站缓存占满'
        }).then(function(input) {
          var reason = (input && input.value) ? input.value : '页面手动回滚';
          self.rollbackLoading = true;
          return self.requestJson(baseUrl + '/ai/autoTune/rollback/auth?reason=' + encodeURIComponent(reason), {
            method: 'POST',
            headers: self.authHeaders()
          });
        }).then(function(res) {
          self.rollbackLoading = false;
          if (!res) {
            return;
          }
          if (res.code === 200) {
            self.$alert(self.prettyJson(res.data || {}), '回滚结果', { confirmButtonText: '确定' });
            self.refreshAll();
            return;
          }
          self.$message.error(res.msg || '回滚失败');
        }).catch(function(error) {
          self.rollbackLoading = false;
          if (error !== 'cancel') {
            self.$message.error('回滚失败');
          }
        });
      },
      openJsonDialog: function(title, data) {
        this.jsonDialogTitle = title;
        this.jsonDialogData = data || {};
        this.jsonDialogVisible = true;
      },
      normalizeCoordinatorResult: function(data, defaultTriggerType) {
        if (data && data.agentResult) {
          return data.agentResult;
        }
        return {
          success: false,
          triggerType: defaultTriggerType || '-',
          summary: 'Agent未触发: ' + ((data && data.reason) ? data.reason : '-'),
          toolCallCount: 0,
          llmCallCount: 0,
          promptTokens: 0,
          completionTokens: 0,
          totalTokens: 0,
          maxRoundsReached: false
        };
      },
      valueOrDash: function(value) {
        if (value === null || value === undefined || value === '') {
          return '-';
        }
        return value;
      },
      mapEntries: function(source) {
        var result = [];
        if (!source) {
          return result;
        }
        var keys = Object.keys(source).sort();
        for (var keyIndex = 0; keyIndex < keys.length; keyIndex++) {
          var key = keys[keyIndex];
          result.push({ key: key, value: source[key] });
        }
        return result;
      },
      combineTaskLimitEntries: function(outMap, inMap) {
        var keySet = {};
        var result = [];
        var outKeys = outMap ? Object.keys(outMap) : [];
        var inKeys = inMap ? Object.keys(inMap) : [];
        for (var outIndex = 0; outIndex < outKeys.length; outIndex++) {
          keySet[outKeys[outIndex]] = true;
        }
        for (var inIndex = 0; inIndex < inKeys.length; inIndex++) {
          keySet[inKeys[inIndex]] = true;
        }
        var keys = Object.keys(keySet).sort();
        for (var keyIndex = 0; keyIndex < keys.length; keyIndex++) {
          var key = keys[keyIndex];
          result.push({
            key: key,
            out: this.valueOrDash(outMap ? outMap[key] : null),
            in: this.valueOrDash(inMap ? inMap[key] : null)
          });
        }
        return result;
      },
      statusType: function(status) {
        if (status === 'success') {
          return 'success';
        }
        if (status === 'failed') {
          return 'danger';
        }
        if (status === 'rejected') {
          return 'warning';
        }
        return 'info';
      },
      formatDateTime: function(input) {
        if (!input) {
          return '-';
        }
        var date = input instanceof Date ? input : new Date(input);
        if (isNaN(date.getTime())) {
          return String(input);
        }
        var pad = function(value) {
          return value < 10 ? ('0' + value) : String(value);
        };
        return date.getFullYear() + '-'
          + pad(date.getMonth() + 1) + '-'
          + pad(date.getDate()) + ' '
          + pad(date.getHours()) + ':'
          + pad(date.getMinutes()) + ':'
          + pad(date.getSeconds());
      },
      dateFormatter: function(row, column, cellValue) {
        return this.formatDateTime(cellValue);
      },
      prettyJson: function(value) {
        try {
          return JSON.stringify(value || {}, null, 2);
        } catch (error) {
          return String(value);
        }
      }
    },
    mounted: function() {
      this.refreshAll();
    }
  });
</script>
</body>
</html>
src/test/java/com/zy/ai/service/AutoTuneCoordinatorServiceImplTest.java
@@ -19,6 +19,7 @@
import com.zy.asrs.service.WrkMastService;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import com.zy.system.entity.OperateLog;
import com.zy.system.service.ConfigService;
import com.zy.system.service.OperateLogService;
import org.junit.jupiter.api.BeforeEach;
@@ -422,6 +423,40 @@
    }
    @Test
    void manualTriggerRunsAgentWithRunningLockAndDoesNotWriteSchedulerGuard() {
        AutoTuneAgentService.AutoTuneAgentResult agentResult = successfulAgentResult();
        agentResult.setTriggerType(AutoTuneTriggerType.MANUAL.getCode());
        when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true);
        when(autoTuneAgentService.runAutoTune(AutoTuneTriggerType.MANUAL.getCode())).thenReturn(agentResult);
        AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runManualAutoTune();
        assertFalse(result.getSkipped());
        assertTrue(result.getTriggered());
        assertSame(agentResult, result.getAgentResult());
        verify(autoTuneAgentService).runAutoTune(AutoTuneTriggerType.MANUAL.getCode());
        verify(wrkMastService, never()).count(any(Wrapper.class));
        verify(redisUtil, never()).set(eq(RedisKeyType.AI_AUTO_TUNE_LAST_TRIGGER_GUARD.key), any(), anyLong());
        verify(redisUtil).compareAndDelete(anyString(), anyString());
        ArgumentCaptor<OperateLog> operateLogCaptor = ArgumentCaptor.forClass(OperateLog.class);
        verify(operateLogService).save(operateLogCaptor.capture());
        assertEquals("ai_auto_tune_manual_trigger", operateLogCaptor.getValue().getAction());
    }
    @Test
    void manualTriggerSkipsWhenRunningLockIsNotAcquired() {
        when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(false);
        AutoTuneCoordinatorService.AutoTuneCoordinatorResult result = coordinatorService().runManualAutoTune();
        assertTrue(result.getSkipped());
        assertEquals("running_lock_not_acquired", result.getReason());
        verify(autoTuneAgentService, never()).runAutoTune(anyString());
        verify(redisUtil, never()).compareAndDelete(anyString(), anyString());
    }
    @Test
    void agentExecutesSnapshotDryRunAndRealApplyToolSequence() {
        AutoTuneAgentServiceImpl service = new AutoTuneAgentServiceImpl(
                llmChatService,