#
Junjie
2 天以前 b37e141c00a123cf362fae00c1e63175d41c4bbe
src/main/webapp/views/ai/llm_config.html
@@ -47,6 +47,13 @@
      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;
@@ -260,8 +267,10 @@
          <div class="sub">支持多API、多模型、多Key,额度耗尽或故障自动切换</div>
        </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>
@@ -289,6 +298,7 @@
      </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">
@@ -299,7 +309,11 @@
      <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>
            <el-input
              :value="displayRouteName(route)"
              size="mini"
              placeholder="路由名称"
              @input="handleRouteNameInput(route, $event)"></el-input>
            <div class="route-id-line">#{{ route.id || 'new' }} · 优先级 {{ route.priority || 0 }}</div>
          </div>
          <div class="route-state">
@@ -383,7 +397,7 @@
    </div>
  </div>
  <el-dialog title="LLM调用日志" :visible.sync="logDialogVisible" width="88%" :close-on-click-modal="false">
  <el-dialog :title="i18n('llm.logsTitle', '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>
@@ -447,7 +461,7 @@
    </div>
  </el-dialog>
  <el-dialog :title="logDetailTitle || '日志详情'" :visible.sync="logDetailVisible" width="82%" :close-on-click-modal="false" append-to-body>
  <el-dialog :title="logDetailTitle || i18n('llm.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>
@@ -458,7 +472,7 @@
<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 type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
<script>
  new Vue({
    el: '#app',
@@ -501,6 +515,31 @@
      }
    },
    methods: {
      i18n: function(key, fallback, params) {
        if (window.WCS_I18N && typeof window.WCS_I18N.t === 'function') {
          var translated = window.WCS_I18N.t(key, params);
          if (translated && translated !== key) {
            return translated;
          }
        }
        return fallback || key;
      },
      translateLegacyText: function(text) {
        if (window.WCS_I18N && typeof window.WCS_I18N.tl === 'function') {
          return window.WCS_I18N.tl(text);
        }
        return text;
      },
      displayRouteName: function(route) {
        var value = route && route.name ? String(route.name) : '';
        return this.translateLegacyText(value);
      },
      handleRouteNameInput: function(route, value) {
        if (!route) {
          return;
        }
        route.name = value;
      },
      formatDateTime: function(input) {
        if (!input) return '-';
        var d = input instanceof Date ? input : new Date(input);
@@ -597,6 +636,138 @@
      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(
            '请选择导入方式:覆盖导入会先清空现有路由;点击“合并导入”则按ID更新或新增。',
            '导入确认',
            {
              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);
@@ -659,7 +830,7 @@
          + '错误: ' + (row.errorMessage || '-') + '\n\n'
          + '请求:\n' + (row.requestContent || '-') + '\n\n'
          + '响应:\n' + (row.responseContent || '-');
        this.logDetailTitle = '日志详情 - ' + (row.traceId || row.id || '');
        this.logDetailTitle = this.i18n('llm.logDetailPrefix', '日志详情 - ') + (row.traceId || row.id || '');
        this.logDetailText = text;
        this.logDetailVisible = true;
      },
@@ -864,6 +1035,12 @@
      }
    },
    mounted: function() {
      var self = this;
      if (window.WCS_I18N && typeof window.WCS_I18N.onReady === 'function') {
        window.WCS_I18N.onReady(function() {
          self.$forceUpdate();
        });
      }
      this.loadRoutes();
    }
  });