<!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;
|
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;
|
}
|
.table-shell {
|
border-radius: 12px;
|
overflow: hidden;
|
box-shadow: 0 6px 22px rgba(15, 28, 48, 0.08);
|
border: 1px solid #e8edf5;
|
background: #fff;
|
}
|
.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)); }
|
}
|
</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>
|
</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="table-shell">
|
<el-table :data="routes" stripe height="72vh" v-loading="loading" :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
|
<el-table-column label="名称" width="170">
|
<template slot-scope="scope">
|
<el-input v-model="scope.row.name" size="mini"></el-input>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="Base URL" min-width="220">
|
<template slot-scope="scope">
|
<el-input v-model="scope.row.baseUrl" class="mono" size="mini" placeholder="必填,例如: https://api.deepseek.com"></el-input>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="模型" width="180">
|
<template slot-scope="scope">
|
<el-input v-model="scope.row.model" class="mono" size="mini" placeholder="必填,例如: deepseek-chat"></el-input>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="API Key" min-width="220">
|
<template slot-scope="scope">
|
<el-input v-model="scope.row.apiKey" class="mono" type="password" size="mini" placeholder="必填"></el-input>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="优先级" width="90">
|
<template slot-scope="scope">
|
<el-input-number v-model="scope.row.priority" size="mini" :min="0" :max="99999" :controls="false" style="width:80px;"></el-input-number>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="状态" width="70">
|
<template slot-scope="scope">
|
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0"></el-switch>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="思考" width="70">
|
<template slot-scope="scope">
|
<el-switch v-model="scope.row.thinking" :active-value="1" :inactive-value="0"></el-switch>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="额度切换" width="90">
|
<template slot-scope="scope">
|
<el-switch v-model="scope.row.switchOnQuota" :active-value="1" :inactive-value="0"></el-switch>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="故障切换" width="90">
|
<template slot-scope="scope">
|
<el-switch v-model="scope.row.switchOnError" :active-value="1" :inactive-value="0"></el-switch>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="冷却秒数" width="100">
|
<template slot-scope="scope">
|
<el-input-number v-model="scope.row.cooldownSeconds" size="mini" :min="0" :max="86400" :controls="false" style="width:90px;"></el-input-number>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="统计" min-width="220">
|
<template slot-scope="scope">
|
<div>成功: {{ scope.row.successCount || 0 }} / 失败: {{ scope.row.failCount || 0 }} / 连续失败: {{ scope.row.consecutiveFailCount || 0 }}</div>
|
<div style="color:#909399;">冷却到: {{ scope.row.cooldownUntil || '-' }}</div>
|
<div style="color:#909399;">最近错误: {{ scope.row.lastError || '-' }}</div>
|
</template>
|
</el-table-column>
|
|
<el-table-column label="操作" width="120" fixed="right" align="center">
|
<template slot-scope="scope">
|
<el-dropdown trigger="click" @command="function(cmd){ handleRouteCommand(cmd, scope.row, scope.$index); }">
|
<el-button size="mini" type="primary" plain>
|
操作<i class="el-icon-arrow-down el-icon--right"></i>
|
</el-button>
|
<el-dropdown-menu slot="dropdown">
|
<el-dropdown-item command="test" :disabled="scope.row.__testing === true">
|
{{ scope.row.__testing === true ? '测试中...' : '测试' }}
|
</el-dropdown-item>
|
<el-dropdown-item command="save">保存</el-dropdown-item>
|
<el-dropdown-item command="cooldown">清冷却</el-dropdown-item>
|
<el-dropdown-item command="delete" divided>删除</el-dropdown-item>
|
</el-dropdown-menu>
|
</el-dropdown>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
</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: []
|
};
|
},
|
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: {
|
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);
|
},
|
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>
|