| New file |
| | |
| | | <!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> |