| | |
| | | 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; |
| | |
| | | <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> |
| | |
| | | </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 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"> |
| | |
| | | </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> |
| | |
| | | </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> |
| | |
| | | |
| | | <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', |
| | |
| | | } |
| | | }, |
| | | 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); |
| | |
| | | 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); |
| | |
| | | + '错误: ' + (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; |
| | | }, |
| | |
| | | } |
| | | }, |
| | | mounted: function() { |
| | | var self = this; |
| | | if (window.WCS_I18N && typeof window.WCS_I18N.onReady === 'function') { |
| | | window.WCS_I18N.onReady(function() { |
| | | self.$forceUpdate(); |
| | | }); |
| | | } |
| | | this.loadRoutes(); |
| | | } |
| | | }); |