From 5e492e5d5a2b743e2e99443220d343f72a633f6d Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期二, 03 三月 2026 16:57:52 +0800
Subject: [PATCH] #

---
 src/main/webapp/views/ai/llm_config.html | 1014 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 1,014 insertions(+), 0 deletions(-)

diff --git a/src/main/webapp/views/ai/llm_config.html b/src/main/webapp/views/ai/llm_config.html
new file mode 100644
index 0000000..c80314c
--- /dev/null
+++ b/src/main/webapp/views/ai/llm_config.html
@@ -0,0 +1,1014 @@
+<!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(1200px 500px at 10% -10%, rgba(26, 115, 232, 0.14), transparent 50%),
+        radial-gradient(900px 450px at 100% 0%, rgba(38, 166, 154, 0.11), transparent 55%),
+        #f4f7fb;
+    }
+    .container {
+      max-width: 1640px;
+      margin: 16px auto;
+      padding: 0 14px;
+    }
+    .hero {
+      background: linear-gradient(135deg, #0f4c81 0%, #1f6fb2 45%, #2aa198 100%);
+      color: #fff;
+      border-radius: 14px;
+      padding: 14px 16px;
+      margin-bottom: 10px;
+      box-shadow: 0 10px 28px rgba(23, 70, 110, 0.22);
+    }
+    .hero-top {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 10px;
+    }
+    .hero-title {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+    }
+    .hero-title .main {
+      font-size: 16px;
+      font-weight: 700;
+      letter-spacing: 0.2px;
+    }
+    .hero-title .sub {
+      font-size: 12px;
+      opacity: 0.9;
+    }
+    .hero-actions {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap;
+      justify-content: flex-end;
+    }
+    .summary-grid {
+      margin-top: 10px;
+      display: grid;
+      grid-template-columns: repeat(5, minmax(0, 1fr));
+      gap: 8px;
+    }
+    .summary-card {
+      border-radius: 10px;
+      background: rgba(255, 255, 255, 0.16);
+      border: 1px solid rgba(255, 255, 255, 0.24);
+      padding: 8px 10px;
+      min-height: 56px;
+      backdrop-filter: blur(3px);
+    }
+    .summary-card .k {
+      font-size: 11px;
+      opacity: 0.88;
+    }
+    .summary-card .v {
+      margin-top: 4px;
+      font-size: 22px;
+      font-weight: 700;
+      line-height: 1.1;
+    }
+    .route-board {
+      border-radius: 14px;
+      border: 1px solid #dbe5f2;
+      background:
+        radial-gradient(800px 200px at -10% 0, rgba(52, 119, 201, 0.06), transparent 55%),
+        radial-gradient(700px 220px at 110% 20%, rgba(39, 154, 136, 0.08), transparent 58%),
+        #f9fbff;
+      box-shadow: 0 8px 30px rgba(26, 53, 84, 0.10);
+      padding: 12px;
+      min-height: 64vh;
+    }
+    .route-grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(390px, 1fr));
+      gap: 12px;
+    }
+    .route-card {
+      border-radius: 14px;
+      border: 1px solid #e4ebf5;
+      background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
+      box-shadow: 0 10px 24px rgba(14, 38, 68, 0.08);
+      padding: 12px;
+      transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+      animation: card-in 0.24s ease both;
+    }
+    .route-card:hover {
+      transform: translateY(-2px);
+      box-shadow: 0 14px 26px rgba(14, 38, 68, 0.12);
+      border-color: #d4e2f2;
+    }
+    .route-card.cooling {
+      border-color: #f2d8a2;
+      background: linear-gradient(180deg, #fffdf6 0%, #fffaf0 100%);
+    }
+    .route-card.disabled {
+      opacity: 0.84;
+    }
+    .route-head {
+      display: flex;
+      align-items: flex-start;
+      justify-content: space-between;
+      gap: 8px;
+      margin-bottom: 10px;
+    }
+    .route-title {
+      display: flex;
+      flex-direction: column;
+      gap: 5px;
+      min-width: 0;
+      flex: 1;
+    }
+    .route-id-line {
+      color: #8294aa;
+      font-size: 11px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+    .route-state {
+      display: flex;
+      gap: 6px;
+      align-items: center;
+      flex-wrap: wrap;
+      justify-content: flex-end;
+      max-width: 46%;
+    }
+    .route-fields {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 8px;
+    }
+    .field-full {
+      grid-column: 1 / -1;
+    }
+    .field-label {
+      font-size: 11px;
+      color: #6f8094;
+      margin-bottom: 4px;
+    }
+    .switch-line {
+      margin-top: 10px;
+      display: grid;
+      grid-template-columns: repeat(2, minmax(0, 1fr));
+      gap: 8px;
+    }
+    .switch-item {
+      border: 1px solid #e7edf7;
+      border-radius: 10px;
+      padding: 6px 8px;
+      background: #fff;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      font-size: 12px;
+      color: #2f3f53;
+    }
+    .stats-box {
+      margin-top: 10px;
+      border: 1px solid #e8edf6;
+      border-radius: 10px;
+      background: linear-gradient(180deg, #fcfdff 0%, #f7faff 100%);
+      padding: 8px 10px;
+      font-size: 12px;
+      color: #4c5f76;
+      line-height: 1.6;
+    }
+    .stats-box .light {
+      color: #7f91a8;
+    }
+    .route-actions {
+      margin-top: 10px;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 8px;
+    }
+    .action-left, .action-right {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+    .empty-shell {
+      min-height: 48vh;
+      border-radius: 12px;
+      border: 1px dashed #cfd8e5;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      color: #7d8ea4;
+      gap: 8px;
+      background: rgba(255, 255, 255, 0.55);
+    }
+    .log-toolbar {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      margin-bottom: 10px;
+      flex-wrap: wrap;
+    }
+    .log-text {
+      max-width: 360px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      color: #6c7f95;
+      font-size: 12px;
+    }
+    .log-detail-body {
+      max-height: 62vh;
+      overflow: auto;
+      border: 1px solid #dfe8f3;
+      border-radius: 8px;
+      background: #f8fbff;
+      padding: 10px 12px;
+      white-space: pre-wrap;
+      word-break: break-word;
+      line-height: 1.55;
+      color: #2e3c4f;
+      font-size: 12px;
+      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+    }
+    @keyframes card-in {
+      from { opacity: 0; transform: translateY(8px); }
+      to { opacity: 1; transform: translateY(0); }
+    }
+    .mono {
+      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+      font-size: 12px;
+    }
+    @media (max-width: 1280px) {
+      .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+      .route-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
+      .route-fields { grid-template-columns: 1fr; }
+      .switch-line { grid-template-columns: 1fr; }
+    }
+  </style>
+</head>
+<body>
+<div id="app" class="container">
+  <div class="hero">
+    <div class="hero-top">
+      <div class="hero-title">
+        <div v-html="headerIcon" style="display:flex;"></div>
+        <div>
+          <div class="main">AI閰嶇疆 - LLM璺敱</div>
+          <div class="sub">鏀寔澶欰PI銆佸妯″瀷銆佸Key锛岄搴﹁�楀敖鎴栨晠闅滆嚜鍔ㄥ垏鎹�</div>
+        </div>
+      </div>
+      <div class="hero-actions">
+        <el-button type="primary" size="mini" @click="addRoute">鏂板璺敱</el-button>
+        <el-button size="mini" @click="exportRoutes">瀵煎嚭JSON</el-button>
+        <el-button size="mini" @click="triggerImport">瀵煎叆JSON</el-button>
+        <el-button size="mini" @click="loadRoutes">鍒锋柊</el-button>
+        <el-button size="mini" @click="openLogDialog">璋冪敤鏃ュ織</el-button>
+      </div>
+    </div>
+    <div class="summary-grid">
+      <div class="summary-card">
+        <div class="k">鎬昏矾鐢�</div>
+        <div class="v">{{ summary.total }}</div>
+      </div>
+      <div class="summary-card">
+        <div class="k">鍚敤</div>
+        <div class="v">{{ summary.enabled }}</div>
+      </div>
+      <div class="summary-card">
+        <div class="k">鏁呴殰鍒囨崲寮�鍚�</div>
+        <div class="v">{{ summary.errorSwitch }}</div>
+      </div>
+      <div class="summary-card">
+        <div class="k">棰濆害鍒囨崲寮�鍚�</div>
+        <div class="v">{{ summary.quotaSwitch }}</div>
+      </div>
+      <div class="summary-card">
+        <div class="k">鍐峰嵈涓�</div>
+        <div class="v">{{ summary.cooling }}</div>
+      </div>
+    </div>
+  </div>
+  <input ref="importFileInput" type="file" accept="application/json,.json" style="display:none;" @change="handleImportFileChange" />
+
+  <div class="route-board" v-loading="loading">
+    <div v-if="!routes || routes.length === 0" class="empty-shell">
+      <div style="font-size:14px;font-weight:600;">鏆傛棤璺敱閰嶇疆</div>
+      <div style="font-size:12px;">鐐瑰嚮鍙充笂瑙掆�滄柊澧炶矾鐢扁�濆垱寤虹涓�鏉¢厤缃�</div>
+    </div>
+    <div v-else class="route-grid">
+      <div class="route-card" :class="routeCardClass(route)" v-for="(route, idx) in routes" :key="route.id ? ('route_' + route.id) : ('new_' + idx)">
+        <div class="route-head">
+          <div class="route-title">
+            <el-input v-model="route.name" size="mini" placeholder="璺敱鍚嶇О"></el-input>
+            <div class="route-id-line">#{{ route.id || 'new' }} 路 浼樺厛绾� {{ route.priority || 0 }}</div>
+          </div>
+          <div class="route-state">
+            <el-tag size="mini" :type="route.status === 1 ? 'success' : 'info'">{{ route.status === 1 ? '鍚敤' : '绂佺敤' }}</el-tag>
+            <el-tag size="mini" type="warning" v-if="isRouteCooling(route)">鍐峰嵈涓�</el-tag>
+          </div>
+        </div>
+
+        <div class="route-fields">
+          <div class="field-full">
+            <div class="field-label">Base URL</div>
+            <el-input v-model="route.baseUrl" class="mono" size="mini" placeholder="蹇呭~锛屼緥濡�: https://dashscope.aliyuncs.com/compatible-mode/v1"></el-input>
+          </div>
+          <div>
+            <div class="field-label">妯″瀷</div>
+            <el-input v-model="route.model" class="mono" size="mini" placeholder="蹇呭~"></el-input>
+          </div>
+          <div>
+            <div class="field-label">浼樺厛绾э紙瓒婂皬瓒婁紭鍏堬級</div>
+            <el-input-number v-model="route.priority" size="mini" :min="0" :max="99999" :controls="false" style="width:100%;"></el-input-number>
+          </div>
+          <div class="field-full">
+            <div class="field-label">API Key</div>
+            <el-input v-model="route.apiKey" class="mono" type="password" size="mini" placeholder="蹇呭~">
+              <template slot="append">
+                <el-button type="text" style="padding:0 8px;" @click="copyApiKey(route)">澶嶅埗</el-button>
+              </template>
+            </el-input>
+          </div>
+          <div>
+            <div class="field-label">鍐峰嵈绉掓暟</div>
+            <el-input-number v-model="route.cooldownSeconds" size="mini" :min="0" :max="86400" :controls="false" style="width:100%;"></el-input-number>
+          </div>
+        </div>
+
+        <div class="switch-line">
+          <div class="switch-item">
+            <span>鐘舵��</span>
+            <el-switch v-model="route.status" :active-value="1" :inactive-value="0"></el-switch>
+          </div>
+          <div class="switch-item">
+            <span>鎬濊��</span>
+            <el-switch v-model="route.thinking" :active-value="1" :inactive-value="0"></el-switch>
+          </div>
+          <div class="switch-item">
+            <span>棰濆害鍒囨崲</span>
+            <el-switch v-model="route.switchOnQuota" :active-value="1" :inactive-value="0"></el-switch>
+          </div>
+          <div class="switch-item">
+            <span>鏁呴殰鍒囨崲</span>
+            <el-switch v-model="route.switchOnError" :active-value="1" :inactive-value="0"></el-switch>
+          </div>
+        </div>
+
+        <div class="stats-box">
+          <div>鎴愬姛 {{ route.successCount || 0 }} / 澶辫触 {{ route.failCount || 0 }} / 杩炵画澶辫触 {{ route.consecutiveFailCount || 0 }}</div>
+          <div class="light">鍐峰嵈鍒�: {{ formatDateTime(route.cooldownUntil) }}</div>
+          <div class="light">鏈�杩戦敊璇�: {{ route.lastError || '-' }}</div>
+        </div>
+
+        <div class="route-actions">
+          <div class="action-left">
+            <el-button type="primary" size="mini" @click="saveRoute(route)">淇濆瓨</el-button>
+            <el-button size="mini" :loading="route.__testing === true" @click="testRoute(route)">
+              {{ route.__testing === true ? '娴嬭瘯涓�...' : '娴嬭瘯' }}
+            </el-button>
+          </div>
+          <div class="action-right">
+            <el-dropdown trigger="click" @command="function(cmd){ handleRouteCommand(cmd, route, idx); }">
+              <el-button size="mini" plain>
+                鏇村<i class="el-icon-arrow-down el-icon--right"></i>
+              </el-button>
+              <el-dropdown-menu slot="dropdown">
+                <el-dropdown-item command="cooldown" :disabled="!route.id">娓呭喎鍗�</el-dropdown-item>
+                <el-dropdown-item command="delete" divided>鍒犻櫎</el-dropdown-item>
+              </el-dropdown-menu>
+            </el-dropdown>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <el-dialog title="LLM璋冪敤鏃ュ織" :visible.sync="logDialogVisible" width="88%" :close-on-click-modal="false">
+    <div class="log-toolbar">
+      <el-select v-model="logQuery.scene" size="mini" clearable placeholder="鍦烘櫙" style="width:180px;">
+        <el-option label="chat" value="chat"></el-option>
+        <el-option label="chat_completion" value="chat_completion"></el-option>
+        <el-option label="chat_completion_tools" value="chat_completion_tools"></el-option>
+        <el-option label="chat_stream" value="chat_stream"></el-option>
+        <el-option label="chat_stream_tools" value="chat_stream_tools"></el-option>
+      </el-select>
+      <el-select v-model="logQuery.success" size="mini" clearable placeholder="缁撴灉" style="width:120px;">
+        <el-option label="鎴愬姛" :value="1"></el-option>
+        <el-option label="澶辫触" :value="0"></el-option>
+      </el-select>
+      <el-input v-model="logQuery.traceId" size="mini" placeholder="traceId" style="width:260px;"></el-input>
+      <el-button type="primary" size="mini" @click="loadLogs(1)">鏌ヨ</el-button>
+      <el-button size="mini" @click="resetLogQuery">閲嶇疆</el-button>
+      <el-button type="danger" plain size="mini" @click="clearLogs">娓呯┖鏃ュ織</el-button>
+    </div>
+
+    <el-table :data="logPage.records" border stripe height="56vh" v-loading="logLoading" :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
+      <el-table-column label="鏃堕棿" width="165">
+        <template slot-scope="scope">
+          {{ formatDateTime(scope.row.createTime) }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="scene" label="鍦烘櫙" width="165"></el-table-column>
+      <el-table-column prop="attemptNo" label="灏濊瘯" width="70"></el-table-column>
+      <el-table-column prop="routeName" label="璺敱" width="170"></el-table-column>
+      <el-table-column prop="model" label="妯″瀷" width="150"></el-table-column>
+      <el-table-column label="缁撴灉" width="85">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.success === 1 ? 'success' : 'danger'">
+            {{ scope.row.success === 1 ? '鎴愬姛' : '澶辫触' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="httpStatus" label="鐘舵�佺爜" width="90"></el-table-column>
+      <el-table-column prop="latencyMs" label="鑰楁椂(ms)" width="95"></el-table-column>
+      <el-table-column prop="traceId" label="TraceId" width="230"></el-table-column>
+      <el-table-column label="閿欒" min-width="220">
+        <template slot-scope="scope">
+          <div class="log-text">{{ scope.row.errorMessage || '-' }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="鎿嶄綔" width="120" fixed="right">
+        <template slot-scope="scope">
+          <el-button type="text" size="mini" @click="showLogDetail(scope.row)">璇︽儏</el-button>
+          <el-button type="text" size="mini" style="color:#F56C6C;" @click="deleteLog(scope.row)">鍒犻櫎</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div style="margin-top:10px;text-align:right;">
+      <el-pagination
+        background
+        layout="total, prev, pager, next"
+        :current-page="logPage.curr"
+        :page-size="logPage.limit"
+        :total="logPage.total"
+        @current-change="loadLogs">
+      </el-pagination>
+    </div>
+  </el-dialog>
+
+  <el-dialog :title="logDetailTitle || '鏃ュ織璇︽儏'" :visible.sync="logDetailVisible" width="82%" :close-on-click-modal="false" append-to-body>
+    <div class="log-detail-body">{{ logDetailText || '-' }}</div>
+    <span slot="footer" class="dialog-footer">
+      <el-button size="mini" @click="copyText(logDetailText)">澶嶅埗鍏ㄦ枃</el-button>
+      <el-button type="primary" size="mini" @click="logDetailVisible = false">鍏抽棴</el-button>
+    </span>
+  </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(34, 34),
+        loading: false,
+        routes: [],
+        logDialogVisible: false,
+        logLoading: false,
+        logDetailVisible: false,
+        logDetailTitle: '',
+        logDetailText: '',
+        logQuery: {
+          scene: '',
+          success: '',
+          traceId: ''
+        },
+        logPage: {
+          records: [],
+          curr: 1,
+          limit: 20,
+          total: 0
+        }
+      };
+    },
+    computed: {
+      summary: function() {
+        var now = Date.now();
+        var total = this.routes.length;
+        var enabled = 0, quotaSwitch = 0, errorSwitch = 0, cooling = 0;
+        for (var i = 0; i < this.routes.length; i++) {
+          var x = this.routes[i];
+          if (x.status === 1) enabled++;
+          if (x.switchOnQuota === 1) quotaSwitch++;
+          if (x.switchOnError === 1) errorSwitch++;
+          if (x.cooldownUntil && new Date(x.cooldownUntil).getTime() > now) cooling++;
+        }
+        return { total: total, enabled: enabled, quotaSwitch: quotaSwitch, errorSwitch: errorSwitch, cooling: cooling };
+      }
+    },
+    methods: {
+      formatDateTime: function(input) {
+        if (!input) return '-';
+        var d = input instanceof Date ? input : new Date(input);
+        if (isNaN(d.getTime())) return String(input);
+        var pad = function(n) { return n < 10 ? ('0' + n) : String(n); };
+        var y = d.getFullYear();
+        var m = pad(d.getMonth() + 1);
+        var day = pad(d.getDate());
+        var h = pad(d.getHours());
+        var mm = pad(d.getMinutes());
+        var s = pad(d.getSeconds());
+        return y + '-' + m + '-' + day + ' ' + h + ':' + mm + ':' + s;
+      },
+      isRouteCooling: function(route) {
+        if (!route || !route.cooldownUntil) return false;
+        var x = new Date(route.cooldownUntil).getTime();
+        return !isNaN(x) && x > Date.now();
+      },
+      routeCardClass: function(route) {
+        return {
+          cooling: this.isRouteCooling(route),
+          disabled: route && route.status !== 1
+        };
+      },
+      copyApiKey: function(route) {
+        var self = this;
+        var text = route && route.apiKey ? String(route.apiKey) : '';
+        if (!text) {
+          self.$message.warning('API Key 涓虹┖');
+          return;
+        }
+
+        var afterCopy = function(ok) {
+          if (ok) self.$message.success('API Key 宸插鍒�');
+          else self.$message.error('澶嶅埗澶辫触锛岃鎵嬪姩澶嶅埗');
+        };
+
+        if (navigator && navigator.clipboard && window.isSecureContext) {
+          navigator.clipboard.writeText(text)
+            .then(function(){ afterCopy(true); })
+            .catch(function(){ afterCopy(false); });
+          return;
+        }
+
+        var ta = document.createElement('textarea');
+        ta.value = text;
+        ta.setAttribute('readonly', 'readonly');
+        ta.style.position = 'fixed';
+        ta.style.left = '-9999px';
+        document.body.appendChild(ta);
+        ta.focus();
+        ta.select();
+        var ok = false;
+        try {
+          ok = document.execCommand('copy');
+        } catch (e) {
+          ok = false;
+        }
+        document.body.removeChild(ta);
+        afterCopy(ok);
+      },
+      copyText: function(text) {
+        var self = this;
+        var val = text ? String(text) : '';
+        if (!val) {
+          self.$message.warning('娌℃湁鍙鍒跺唴瀹�');
+          return;
+        }
+        var done = function(ok) {
+          if (ok) self.$message.success('宸插鍒�');
+          else self.$message.error('澶嶅埗澶辫触锛岃鎵嬪姩澶嶅埗');
+        };
+        if (navigator && navigator.clipboard && window.isSecureContext) {
+          navigator.clipboard.writeText(val).then(function(){ done(true); }).catch(function(){ done(false); });
+          return;
+        }
+        var ta = document.createElement('textarea');
+        ta.value = val;
+        ta.setAttribute('readonly', 'readonly');
+        ta.style.position = 'fixed';
+        ta.style.left = '-9999px';
+        document.body.appendChild(ta);
+        ta.focus();
+        ta.select();
+        var ok = false;
+        try {
+          ok = document.execCommand('copy');
+        } catch (e) {
+          ok = false;
+        }
+        document.body.removeChild(ta);
+        done(ok);
+      },
+      authHeaders: function() {
+        return { 'token': localStorage.getItem('token') };
+      },
+      exportRoutes: function() {
+        var self = this;
+        fetch(baseUrl + '/ai/llm/config/export/auth', { headers: self.authHeaders() })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            if (!res || res.code !== 200) {
+              self.$message.error((res && res.msg) ? res.msg : '瀵煎嚭澶辫触');
+              return;
+            }
+            var payload = res.data || {};
+            var text = JSON.stringify(payload, null, 2);
+            var name = 'llm_routes_' + self.buildExportTimestamp() + '.json';
+            var blob = new Blob([text], { type: 'application/json;charset=utf-8' });
+            var a = document.createElement('a');
+            a.href = URL.createObjectURL(blob);
+            a.download = name;
+            document.body.appendChild(a);
+            a.click();
+            setTimeout(function() {
+              URL.revokeObjectURL(a.href);
+              document.body.removeChild(a);
+            }, 0);
+            self.$message.success('瀵煎嚭鎴愬姛');
+          })
+          .catch(function(){
+            self.$message.error('瀵煎嚭澶辫触');
+          });
+      },
+      buildExportTimestamp: function() {
+        var d = new Date();
+        var pad = function(n) { return n < 10 ? ('0' + n) : String(n); };
+        return d.getFullYear()
+          + pad(d.getMonth() + 1)
+          + pad(d.getDate())
+          + '_'
+          + pad(d.getHours())
+          + pad(d.getMinutes())
+          + pad(d.getSeconds());
+      },
+      triggerImport: function() {
+        var input = this.$refs.importFileInput;
+        if (!input) return;
+        input.value = '';
+        input.click();
+      },
+      handleImportFileChange: function(evt) {
+        var self = this;
+        var files = evt && evt.target && evt.target.files ? evt.target.files : null;
+        var file = files && files.length > 0 ? files[0] : null;
+        if (!file) return;
+        var reader = new FileReader();
+        reader.onload = function(e) {
+          var text = e && e.target ? e.target.result : '';
+          var parsed;
+          try {
+            parsed = JSON.parse(text || '{}');
+          } catch (err) {
+            self.$message.error('JSON 鏍煎紡涓嶆纭�');
+            return;
+          }
+          var routes = self.extractImportRoutes(parsed);
+          if (!routes || routes.length === 0) {
+            self.$message.warning('鏈壘鍒板彲瀵煎叆鐨� routes');
+            return;
+          }
+          self.$confirm(
+            '璇烽�夋嫨瀵煎叆鏂瑰紡锛氳鐩栧鍏ヤ細鍏堟竻绌虹幇鏈夎矾鐢憋紱鐐瑰嚮鈥滃悎骞跺鍏モ�濆垯鎸塈D鏇存柊鎴栨柊澧炪��',
+            '瀵煎叆纭',
+            {
+              type: 'warning',
+              distinguishCancelAndClose: true,
+              confirmButtonText: '瑕嗙洊瀵煎叆',
+              cancelButtonText: '鍚堝苟瀵煎叆',
+              closeOnClickModal: false
+            }
+          ).then(function() {
+            self.doImportRoutes(routes, true);
+          }).catch(function(action) {
+            if (action === 'cancel') {
+              self.doImportRoutes(routes, false);
+            }
+          });
+        };
+        reader.onerror = function() {
+          self.$message.error('璇诲彇鏂囦欢澶辫触');
+        };
+        reader.readAsText(file, 'utf-8');
+      },
+      extractImportRoutes: function(parsed) {
+        if (Array.isArray(parsed)) return parsed;
+        if (!parsed || typeof parsed !== 'object') return [];
+        if (Array.isArray(parsed.routes)) return parsed.routes;
+        if (parsed.data && Array.isArray(parsed.data.routes)) return parsed.data.routes;
+        if (Array.isArray(parsed.data)) return parsed.data;
+        return [];
+      },
+      doImportRoutes: function(routes, replace) {
+        var self = this;
+        fetch(baseUrl + '/ai/llm/config/import/auth', {
+          method: 'POST',
+          headers: Object.assign({ 'Content-Type': 'application/json' }, self.authHeaders()),
+          body: JSON.stringify({
+            replace: replace === true,
+            routes: routes
+          })
+        })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            if (!res || res.code !== 200) {
+              self.$message.error((res && res.msg) ? res.msg : '瀵煎叆澶辫触');
+              return;
+            }
+            var d = res.data || {};
+            var msg = '瀵煎叆瀹屾垚锛氭柊澧� ' + (d.inserted || 0)
+              + '锛屾洿鏂� ' + (d.updated || 0)
+              + '锛岃烦杩� ' + (d.skipped || 0);
+            if (d.errorCount && d.errorCount > 0) {
+              msg += '锛屽紓甯� ' + d.errorCount;
+            }
+            self.$message.success(msg);
+            if (Array.isArray(d.errors) && d.errors.length > 0) {
+              self.$alert(d.errors.join('\n'), '瀵煎叆寮傚父鏄庣粏锛堟渶澶�20鏉★級', {
+                confirmButtonText: '纭畾',
+                type: 'warning'
+              });
+            }
+            self.loadRoutes();
+          })
+          .catch(function(){
+            self.$message.error('瀵煎叆澶辫触');
+          });
+      },
+      handleRouteCommand: function(command, route, idx) {
+        if (command === 'test') return this.testRoute(route);
+        if (command === 'save') return this.saveRoute(route);
+        if (command === 'cooldown') return this.clearCooldown(route);
+        if (command === 'delete') return this.deleteRoute(route, idx);
+      },
+      openLogDialog: function() {
+        this.logDialogVisible = true;
+        this.loadLogs(1);
+      },
+      resetLogQuery: function() {
+        this.logQuery.scene = '';
+        this.logQuery.success = '';
+        this.logQuery.traceId = '';
+        this.loadLogs(1);
+      },
+      buildLogQuery: function(curr) {
+        var q = [];
+        q.push('curr=' + encodeURIComponent(curr || 1));
+        q.push('limit=' + encodeURIComponent(this.logPage.limit));
+        if (this.logQuery.scene) q.push('scene=' + encodeURIComponent(this.logQuery.scene));
+        if (this.logQuery.success !== '' && this.logQuery.success !== null && this.logQuery.success !== undefined) {
+          q.push('success=' + encodeURIComponent(this.logQuery.success));
+        }
+        if (this.logQuery.traceId) q.push('traceId=' + encodeURIComponent(this.logQuery.traceId));
+        return q.join('&');
+      },
+      loadLogs: function(curr) {
+        var self = this;
+        self.logLoading = true;
+        fetch(baseUrl + '/ai/llm/log/list/auth?' + self.buildLogQuery(curr), { headers: self.authHeaders() })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            self.logLoading = false;
+            if (!res || res.code !== 200) {
+              self.$message.error((res && res.msg) ? res.msg : '鏃ュ織鍔犺浇澶辫触');
+              return;
+            }
+            var p = res.data || {};
+            self.logPage.records = Array.isArray(p.records) ? p.records : [];
+            self.logPage.curr = p.current || curr || 1;
+            self.logPage.limit = p.size || self.logPage.limit;
+            self.logPage.total = p.total || 0;
+          })
+          .catch(function(){
+            self.logLoading = false;
+            self.$message.error('鏃ュ織鍔犺浇澶辫触');
+          });
+      },
+      showLogDetail: function(row) {
+        var text = ''
+          + '鏃堕棿: ' + this.formatDateTime(row.createTime) + '\n'
+          + 'TraceId: ' + (row.traceId || '-') + '\n'
+          + '鍦烘櫙: ' + (row.scene || '-') + '\n'
+          + '璺敱: ' + (row.routeName || '-') + '\n'
+          + '妯″瀷: ' + (row.model || '-') + '\n'
+          + '鐘舵�佺爜: ' + (row.httpStatus != null ? row.httpStatus : '-') + '\n'
+          + '鑰楁椂: ' + (row.latencyMs != null ? row.latencyMs : '-') + ' ms\n'
+          + '缁撴灉: ' + (row.success === 1 ? '鎴愬姛' : '澶辫触') + '\n'
+          + '閿欒: ' + (row.errorMessage || '-') + '\n\n'
+          + '璇锋眰:\n' + (row.requestContent || '-') + '\n\n'
+          + '鍝嶅簲:\n' + (row.responseContent || '-');
+        this.logDetailTitle = '鏃ュ織璇︽儏 - ' + (row.traceId || row.id || '');
+        this.logDetailText = text;
+        this.logDetailVisible = true;
+      },
+      deleteLog: function(row) {
+        var self = this;
+        if (!row || !row.id) return;
+        self.$confirm('纭畾鍒犻櫎璇ユ棩蹇楀悧锛�', '鎻愮ず', { type: 'warning' }).then(function() {
+          fetch(baseUrl + '/ai/llm/log/delete/auth?id=' + encodeURIComponent(row.id), {
+            method: 'POST',
+            headers: self.authHeaders()
+          })
+            .then(function(r){ return r.json(); })
+            .then(function(res){
+              if (res && res.code === 200) {
+                self.$message.success('鍒犻櫎鎴愬姛');
+                self.loadLogs(self.logPage.curr);
+              } else {
+                self.$message.error((res && res.msg) ? res.msg : '鍒犻櫎澶辫触');
+              }
+            })
+            .catch(function(){
+              self.$message.error('鍒犻櫎澶辫触');
+            });
+        }).catch(function(){});
+      },
+      clearLogs: function() {
+        var self = this;
+        self.$confirm('纭畾娓呯┖鍏ㄩ儴LLM璋冪敤鏃ュ織鍚楋紵', '鎻愮ず', { type: 'warning' }).then(function() {
+          fetch(baseUrl + '/ai/llm/log/clear/auth', {
+            method: 'POST',
+            headers: self.authHeaders()
+          })
+            .then(function(r){ return r.json(); })
+            .then(function(res){
+              if (res && res.code === 200) {
+                self.$message.success('宸叉竻绌�');
+                self.loadLogs(1);
+              } else {
+                self.$message.error((res && res.msg) ? res.msg : '娓呯┖澶辫触');
+              }
+            })
+            .catch(function(){
+              self.$message.error('娓呯┖澶辫触');
+            });
+        }).catch(function(){});
+      },
+      loadRoutes: function() {
+        var self = this;
+        self.loading = true;
+        fetch(baseUrl + '/ai/llm/config/list/auth', { headers: self.authHeaders() })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            self.loading = false;
+            if (res && res.code === 200) {
+              self.routes = Array.isArray(res.data) ? res.data : [];
+            } else {
+              self.$message.error((res && res.msg) ? res.msg : '鍔犺浇澶辫触');
+            }
+          })
+          .catch(function(){
+            self.loading = false;
+            self.$message.error('鍔犺浇澶辫触');
+          });
+      },
+      addRoute: function() {
+        this.routes.unshift({
+          id: null,
+          name: '',
+          baseUrl: '',
+          apiKey: '',
+          model: '',
+          thinking: 0,
+          priority: 100,
+          status: 1,
+          switchOnQuota: 1,
+          switchOnError: 1,
+          cooldownSeconds: 300,
+          successCount: 0,
+          failCount: 0,
+          consecutiveFailCount: 0,
+          cooldownUntil: null,
+          lastError: null
+        });
+      },
+      buildPayload: function(route) {
+        return {
+          id: route.id,
+          name: route.name,
+          baseUrl: route.baseUrl,
+          apiKey: route.apiKey,
+          model: route.model,
+          thinking: route.thinking,
+          priority: route.priority,
+          status: route.status,
+          switchOnQuota: route.switchOnQuota,
+          switchOnError: route.switchOnError,
+          cooldownSeconds: route.cooldownSeconds,
+          memo: route.memo
+        };
+      },
+      saveRoute: function(route) {
+        var self = this;
+        fetch(baseUrl + '/ai/llm/config/save/auth', {
+          method: 'POST',
+          headers: Object.assign({ 'Content-Type': 'application/json' }, self.authHeaders()),
+          body: JSON.stringify(self.buildPayload(route))
+        })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            if (res && res.code === 200) {
+              self.$message.success('淇濆瓨鎴愬姛');
+              self.loadRoutes();
+            } else {
+              self.$message.error((res && res.msg) ? res.msg : '淇濆瓨澶辫触');
+            }
+          })
+          .catch(function(){
+            self.$message.error('淇濆瓨澶辫触');
+          });
+      },
+      deleteRoute: function(route, idx) {
+        var self = this;
+        if (!route.id) {
+          self.routes.splice(idx, 1);
+          return;
+        }
+        self.$confirm('纭畾鍒犻櫎璇ヨ矾鐢卞悧锛�', '鎻愮ず', { type: 'warning' }).then(function() {
+        fetch(baseUrl + '/ai/llm/config/delete/auth?id=' + encodeURIComponent(route.id), {
+          method: 'POST',
+          headers: self.authHeaders()
+        })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            if (res && res.code === 200) {
+              self.$message.success('鍒犻櫎鎴愬姛');
+              self.loadRoutes();
+            } else {
+              self.$message.error((res && res.msg) ? res.msg : '鍒犻櫎澶辫触');
+            }
+          })
+          .catch(function(){
+            self.$message.error('鍒犻櫎澶辫触');
+          });
+        }).catch(function(){});
+      },
+      clearCooldown: function(route) {
+        var self = this;
+        if (!route.id) return;
+        fetch(baseUrl + '/ai/llm/config/clearCooldown/auth?id=' + encodeURIComponent(route.id), {
+          method: 'POST',
+          headers: self.authHeaders()
+        })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            if (res && res.code === 200) {
+              self.$message.success('宸叉竻闄ゅ喎鍗�');
+              self.loadRoutes();
+            } else {
+              self.$message.error((res && res.msg) ? res.msg : '鎿嶄綔澶辫触');
+            }
+          })
+          .catch(function(){
+            self.$message.error('鎿嶄綔澶辫触');
+          });
+      },
+      testRoute: function(route) {
+        var self = this;
+        if (route.__testing === true) return;
+        if (!route.id) {
+          self.$message.warning('褰撳墠鏄湭淇濆瓨閰嶇疆锛屾祴璇曢�氳繃鍚庝粛闇�鍏堜繚瀛樻墠浼氱敓鏁�');
+        }
+        self.$set(route, '__testing', true);
+        fetch(baseUrl + '/ai/llm/config/test/auth', {
+          method: 'POST',
+          headers: Object.assign({ 'Content-Type': 'application/json' }, self.authHeaders()),
+          body: JSON.stringify(self.buildPayload(route))
+        })
+          .then(function(r){ return r.json(); })
+          .then(function(res){
+            if (!res || res.code !== 200) {
+              self.$message.error((res && res.msg) ? res.msg : '娴嬭瘯澶辫触');
+              return;
+            }
+            var data = res.data || {};
+            var ok = data.ok === true;
+            var title = ok ? '娴嬭瘯鎴愬姛' : '娴嬭瘯澶辫触';
+            var msg = ''
+              + '璺敱: ' + (route.name || '-') + '\n'
+              + 'Base URL: ' + (route.baseUrl || '-') + '\n'
+              + '鐘舵�佺爜: ' + (data.statusCode != null ? data.statusCode : '-') + '\n'
+              + '鑰楁椂: ' + (data.latencyMs != null ? data.latencyMs : '-') + ' ms\n'
+              + '缁撴灉: ' + (data.message || '-') + '\n'
+              + '杩斿洖鐗囨: ' + (data.responseSnippet || '-');
+            self.$alert(msg, title, { confirmButtonText: '纭畾', type: ok ? 'success' : 'error' });
+          })
+          .catch(function(){
+            self.$message.error('娴嬭瘯澶辫触');
+          })
+          .finally(function(){
+            self.$set(route, '__testing', false);
+          });
+      }
+    },
+    mounted: function() {
+      this.loadRoutes();
+    }
+  });
+</script>
+</body>
+</html>

--
Gitblit v1.9.1