<!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;
|
}
|
.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">支持多API、多模型、多Key,额度耗尽或故障自动切换</div>
|
</div>
|
</div>
|
<div>
|
<el-button type="primary" size="mini" @click="addRoute">新增路由</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>
|
|
<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') };
|
},
|
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>
|