Files
doc-forge/web/src/views/ModelManage.vue
T
2026-07-03 11:14:24 +08:00

300 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>