300 lines
8.7 KiB
Vue
300 lines
8.7 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<div class="breadcrumb">
|
||
<span>模型管理</span>
|
||
<span>/</span>
|
||
<span>AI 模型配置</span>
|
||
</div>
|
||
|
||
<div class="panel-card">
|
||
<div class="panel-head">
|
||
<div class="panel-title">可用模型列表</div>
|
||
<a-button @click="openAdd">添加模型</a-button>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="model-card" v-for="item in models" :key="item.id">
|
||
<div class="mc-icon">{{ item.name?.slice(0, 1)?.toUpperCase() || 'M' }}</div>
|
||
<div class="mc-info">
|
||
<div class="mc-name">{{ item.name }}</div>
|
||
<div class="mc-provider">{{ item.provider }} · {{ item.api_endpoint }}</div>
|
||
<div class="mc-provider">密钥:{{ item.api_key_preview || '未设置' }}</div>
|
||
<div class="mc-provider">流式传输:{{ item.supports_streaming ? '支持' : '关闭' }}</div>
|
||
<div class="mc-provider">思考模式:{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
|
||
<div v-if="isDeepSeek(item) && balanceMap[item.id]" class="mc-provider">
|
||
余额:{{ formatBalanceText(balanceMap[item.id]) }}
|
||
</div>
|
||
</div>
|
||
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
|
||
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
|
||
</div>
|
||
<div class="mc-actions">
|
||
<a-button size="small" :loading="testingMap[item.id]" @click="runTest(item)">
|
||
<template #icon><reload-outlined /></template>
|
||
刷新测试
|
||
</a-button>
|
||
<a-button v-if="isDeepSeek(item)" size="small" :loading="balanceLoadingMap[item.id]" @click="fetchBalance(item)">
|
||
查看余额
|
||
</a-button>
|
||
<a-button size="small" @click="openEdit(item)">编辑</a-button>
|
||
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<a-modal v-model:open="modalOpen" :title="isEdit ? '编辑模型' : '添加模型'" @ok="saveModel">
|
||
<a-form layout="vertical">
|
||
<a-form-item label="模型名称">
|
||
<a-input v-model:value="form.name" />
|
||
</a-form-item>
|
||
<a-form-item label="供应厂商">
|
||
<a-select v-model:value="providerPreset" @change="applyProviderPreset">
|
||
<a-select-option value="deepseek">DeepSeek</a-select-option>
|
||
<a-select-option value="custom">自定义</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item v-if="providerPreset === 'custom'" label="自定义厂商名称">
|
||
<a-input v-model:value="form.provider" placeholder="例如 OpenAI / Anthropic / 其他" />
|
||
</a-form-item>
|
||
<a-form-item label="API 格式">
|
||
<a-select v-model:value="form.api_format">
|
||
<a-select-option value="openai">OpenAI 格式</a-select-option>
|
||
<a-select-option value="anthropic">Anthropic 格式</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="API 地址">
|
||
<a-input v-model:value="form.api_endpoint" />
|
||
</a-form-item>
|
||
<a-form-item label="支持流式传输">
|
||
<a-switch v-model:checked="form.supports_streaming" />
|
||
</a-form-item>
|
||
<a-form-item label="开启思考模式">
|
||
<a-switch v-model:checked="form.enable_reasoning" />
|
||
</a-form-item>
|
||
<a-form-item label="API Key">
|
||
<a-input-password v-model:value="form.api_key" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||
import { useModelStore } from '@/stores/model'
|
||
|
||
const store = useModelStore()
|
||
const models = ref<any[]>([])
|
||
const modalOpen = ref(false)
|
||
const isEdit = ref(false)
|
||
const editId = ref(0)
|
||
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false })
|
||
const providerPreset = ref<'deepseek' | 'custom'>('custom')
|
||
const testingMap = ref<Record<number, boolean>>({})
|
||
const balanceLoadingMap = ref<Record<number, boolean>>({})
|
||
const balanceMap = ref<Record<number, { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }>>({})
|
||
|
||
function isDeepSeek(item: any) {
|
||
const provider = (item?.provider || '').trim().toLowerCase()
|
||
return provider === 'deepseek'
|
||
}
|
||
|
||
function applyProviderPreset(value: 'deepseek' | 'custom') {
|
||
if (value === 'deepseek') {
|
||
form.value.provider = 'DeepSeek'
|
||
form.value.api_format = 'openai'
|
||
if (!form.value.api_endpoint || form.value.api_endpoint.includes('deepseek')) {
|
||
form.value.api_endpoint = 'https://api.deepseek.com'
|
||
}
|
||
return
|
||
}
|
||
if (form.value.provider === 'DeepSeek') {
|
||
form.value.provider = ''
|
||
}
|
||
}
|
||
|
||
function inferProviderPreset(item?: any) {
|
||
providerPreset.value = isDeepSeek(item || form.value) ? 'deepseek' : 'custom'
|
||
}
|
||
|
||
function formatBalanceText(data: { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }) {
|
||
const infos = data?.balance_infos || []
|
||
if (!infos.length) return data?.is_available ? '可用' : '不可用'
|
||
return infos
|
||
.map((item) => `${item.currency} ${item.total_balance}(充值 ${item.topped_up_balance} / 赠送 ${item.granted_balance})`)
|
||
.join(';')
|
||
}
|
||
|
||
async function refreshList() {
|
||
await store.fetchList()
|
||
models.value = store.models as any
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await refreshList()
|
||
})
|
||
|
||
function openAdd() {
|
||
isEdit.value = false
|
||
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
|
||
providerPreset.value = 'deepseek'
|
||
applyProviderPreset('deepseek')
|
||
modalOpen.value = true
|
||
}
|
||
|
||
function openEdit(item: any) {
|
||
isEdit.value = true
|
||
editId.value = item.id
|
||
form.value = {
|
||
name: item.name,
|
||
provider: item.provider,
|
||
api_format: item.api_format,
|
||
api_endpoint: item.api_endpoint,
|
||
api_key: '',
|
||
supports_streaming: !!item.supports_streaming,
|
||
enable_reasoning: !!item.enable_reasoning,
|
||
}
|
||
inferProviderPreset(item)
|
||
modalOpen.value = true
|
||
}
|
||
|
||
async function saveModel() {
|
||
if (isEdit.value) {
|
||
await store.update(editId.value, form.value)
|
||
} else {
|
||
await store.create(form.value)
|
||
}
|
||
modalOpen.value = false
|
||
await refreshList()
|
||
message.success('保存成功')
|
||
}
|
||
|
||
async function toggleStatus(item: any) {
|
||
await store.update(item.id, { status: item.status === 'enabled' ? 'disabled' : 'enabled' })
|
||
await refreshList()
|
||
message.success('状态已更新')
|
||
}
|
||
|
||
async function runTest(item: any) {
|
||
testingMap.value[item.id] = true
|
||
try {
|
||
const result: any = await store.test(item.id)
|
||
message.success(result.data?.message || `模型 ${item.name} 测试成功`, 2)
|
||
} catch (error: any) {
|
||
message.error(error.message || '连接测试失败', 2)
|
||
} finally {
|
||
testingMap.value[item.id] = false
|
||
}
|
||
}
|
||
|
||
async function fetchBalance(item: any) {
|
||
balanceLoadingMap.value[item.id] = true
|
||
try {
|
||
const result: any = await store.balance(item.id)
|
||
balanceMap.value[item.id] = result.data
|
||
message.success(`已刷新 ${item.name} 余额`, 2)
|
||
} catch (error: any) {
|
||
message.error(error.message || '余额查询失败', 2)
|
||
} finally {
|
||
balanceLoadingMap.value[item.id] = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-wrap {
|
||
padding: 24px 32px 32px;
|
||
}
|
||
|
||
.breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: #9aa1ad;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.panel-card {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
border: 1px solid #e0e2e6;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 24px;
|
||
border-bottom: 1px solid #eaecef;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.panel-body {
|
||
padding: 24px;
|
||
}
|
||
|
||
.model-card {
|
||
border: 1px solid #eaecef;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.mc-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 8px;
|
||
background: #eeeefb;
|
||
color: #5b5bd6;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.mc-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.mc-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.mc-provider {
|
||
font-size: 12px;
|
||
color: #9aa1ad;
|
||
}
|
||
|
||
.mc-status {
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mc-status.on {
|
||
color: #1a8c4a;
|
||
}
|
||
|
||
.mc-status.off {
|
||
color: #9aa1ad;
|
||
}
|
||
|
||
.mc-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
</style>
|