feat: reorganize admin llm settings

This commit is contained in:
2026-08-22 22:06:01 +08:00
parent 78c42eff53
commit f8f0b3c4fd
17 changed files with 1019 additions and 432 deletions
+244
View File
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { CloudServerOutlined, KeyOutlined } from '@ant-design/icons-vue'
import { api } from '../api'
import { readError } from '../composables/useAdminConfigs'
interface ApiKeyState {
configured: boolean
masked: string
source: 'database' | 'environment' | 'none'
canManage: boolean
}
interface LlmSettings {
protocol: string
baseUrl: string
model: string
maxTokens: number
temperature: number
apiKey: ApiKeyState
}
const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const savingKey = ref(false)
const deletingKey = ref(false)
const apiKeyInput = ref('')
const testResult = ref<{ ok: boolean; detail: string } | null>(null)
const keyState = ref<ApiKeyState>({ configured: false, masked: '', source: 'none', canManage: false })
const form = reactive({
protocol: 'responses',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
maxTokens: 1024,
temperature: 0.7,
})
const protocols = [
{ value: 'responses', label: 'Responses', detail: 'OpenAI 新版接口,支持 Agent 工具调用' },
{ value: 'chat_completions', label: 'Chat Completions', detail: '兼容 OpenAI 风格的聊天补全服务' },
{ value: 'messages', label: 'Messages', detail: '兼容 Anthropic Claude Messages API' },
]
onMounted(load)
async function load() {
loading.value = true
try { apply(await api.llmSettings()) }
catch (error: any) { message.error(readError(error, '模型配置加载失败')) }
finally { loading.value = false }
}
function apply(settings: LlmSettings) {
form.protocol = settings.protocol || 'responses'
form.baseUrl = settings.baseUrl || 'https://api.openai.com/v1'
form.model = settings.model || 'gpt-4o-mini'
form.maxTokens = settings.maxTokens ?? 1024
form.temperature = settings.temperature ?? 0.7
keyState.value = settings.apiKey
}
async function saveSettings(showSuccess = true) {
saving.value = true
try {
apply(await api.updateLlmSettings({ ...form }))
if (showSuccess) message.success('模型参数已保存')
} catch (error: any) {
message.error(readError(error, '模型参数保存失败'))
throw error
} finally { saving.value = false }
}
async function saveApiKey() {
if (!apiKeyInput.value.trim()) {
message.warning('请输入新的 API Key')
return
}
savingKey.value = true
try {
apply(await api.updateLlmApiKey(apiKeyInput.value))
apiKeyInput.value = ''
message.success('API Key 已加密保存')
} catch (error: any) {
message.error(readError(error, 'API Key 保存失败'))
} finally { savingKey.value = false }
}
async function deleteApiKey() {
deletingKey.value = true
try {
apply(await api.deleteLlmApiKey())
message.success(keyState.value.source === 'environment'
? '数据库密钥已删除,当前回退使用环境变量'
: 'API Key 已删除')
} catch (error: any) {
message.error(readError(error, 'API Key 删除失败'))
} finally { deletingKey.value = false }
}
async function testConnection() {
testing.value = true
testResult.value = null
try {
await saveSettings(false)
const result = await api.testLlm()
testResult.value = { ok: result.ok, detail: result.detail || result.error || '' }
result.ok ? message.success('LLM 连接正常') : message.error(result.error || '连接失败')
} catch (error: any) {
testResult.value = { ok: false, detail: readError(error, '连接失败') }
} finally { testing.value = false }
}
function sourceLabel(source: ApiKeyState['source']) {
if (source === 'database') return '后台加密配置'
if (source === 'environment') return '环境变量回退'
return '未配置'
}
</script>
<template>
<div class="settings-page">
<header class="page-heading">
<div>
<h1>模型服务</h1>
<p>配置 AI 服务连接模型参数和访问密钥测试连接会先保存当前模型参数</p>
</div>
<a-space>
<a-button :loading="testing" @click="testConnection">测试连接</a-button>
<a-button type="primary" :loading="saving" @click="saveSettings()">保存参数</a-button>
</a-space>
</header>
<a-skeleton v-if="loading" active :paragraph="{ rows: 9 }" />
<template v-else>
<a-alert
v-if="testResult"
:type="testResult.ok ? 'success' : 'error'"
:message="testResult.ok ? '连接成功' : '连接失败'"
:description="testResult.detail"
show-icon
closable
class="result-alert"
@close="testResult = null" />
<section class="settings-panel" aria-labelledby="connection-title">
<div class="section-heading">
<CloudServerOutlined />
<div><h2 id="connection-title">连接与生成参数</h2><p>这些参数用于通用对话OCR 等识别任务继续使用各自的低温配置</p></div>
</div>
<a-form layout="vertical" class="form-grid">
<a-form-item label="API 协议" class="span-full">
<a-radio-group v-model:value="form.protocol" class="protocol-grid">
<label v-for="protocol in protocols" :key="protocol.value" class="protocol-option">
<a-radio :value="protocol.value" />
<span><strong>{{ protocol.label }}</strong><small>{{ protocol.detail }}</small></span>
</label>
</a-radio-group>
</a-form-item>
<a-form-item label="API 地址" class="span-full" extra="填写服务根地址,系统会按协议自动追加请求路径。">
<a-input v-model:value="form.baseUrl" placeholder="https://api.openai.com/v1" />
</a-form-item>
<a-form-item label="模型名称">
<a-input v-model:value="form.model" placeholder="gpt-4o-mini" />
</a-form-item>
<a-form-item label="最大输出 Token" extra="单次通用回复的最大输出量,范围 64–4096。">
<a-input-number v-model:value="form.maxTokens" :min="64" :max="4096" style="width:100%" />
</a-form-item>
<a-form-item label="温度" extra="0 更稳定,数值越高回复越灵活;默认 0.7。">
<a-input-number v-model:value="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
</a-form-item>
</a-form>
</section>
<section class="settings-panel key-panel" aria-labelledby="key-title">
<div class="section-heading key-heading">
<KeyOutlined />
<div><h2 id="key-title">API Key</h2><p>密钥保存后只显示尾号完整值不会通过后台接口返回</p></div>
<a-tag :color="keyState.configured ? 'green' : 'default'">{{ sourceLabel(keyState.source) }}</a-tag>
</div>
<div class="key-status">
<span class="status-label">当前密钥</span>
<strong>{{ keyState.configured ? keyState.masked : '尚未配置' }}</strong>
</div>
<template v-if="keyState.canManage">
<div class="key-editor">
<a-input-password
v-model:value="apiKeyInput"
autocomplete="new-password"
placeholder="输入新的 API Key,保存后将替换当前密钥"
@press-enter="saveApiKey" />
<a-button type="primary" :loading="savingKey" @click="saveApiKey">保存密钥</a-button>
<a-popconfirm
v-if="keyState.source === 'database'"
title="删除后台保存的密钥?"
description="删除后将回退使用 LLM_API_KEY;若未配置环境变量,AI 服务会停用。"
ok-text="删除"
cancel-text="取消"
@confirm="deleteApiKey">
<a-button danger :loading="deletingKey">删除</a-button>
</a-popconfirm>
</div>
<p class="key-note">服务端需配置 <code>Secrets__EncryptionKey</code> 才能加密保存值为 Base64 编码的 32 字节密钥</p>
</template>
<a-alert v-else type="info" message="只有超级管理员可以替换或删除 API Key。" show-icon />
</section>
</template>
</div>
</template>
<style scoped>
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.page-heading h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.3; }
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; background: #fff; }
.settings-panel + .settings-panel { margin-top: 18px; }
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 22px; }
.section-heading > :first-child { margin-top: 3px; color: #5b6bf5; font-size: 20px; }
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.span-full { grid-column: 1 / -1; }
.protocol-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; width: 100%; }
.protocol-option { display: flex; align-items: flex-start; gap: 4px; min-height: 82px; padding: 14px; border: 1px solid #e4e7eb; border-radius: 12px; cursor: pointer; transition: border-color .18s ease-out, background-color .18s ease-out; }
.protocol-option:hover { border-color: #9ba5fa; background: #f8f9ff; }
.protocol-option:has(.ant-radio-checked) { border-color: #5b6bf5; background: #f3f4ff; }
.protocol-option strong, .protocol-option small { display: block; }
.protocol-option small { margin-top: 4px; color: #5e6772; line-height: 1.45; }
.key-heading { align-items: center; }
.key-heading > div { flex: 1; }
.key-status { display: flex; align-items: baseline; gap: 18px; padding: 14px 16px; margin-bottom: 16px; border-radius: 12px; background: #f6f7f9; }
.status-label { color: #5e6772; }
.key-status strong { font-variant-numeric: tabular-nums; letter-spacing: .04em; }
.key-editor { display: grid; grid-template-columns: minmax(240px, 1fr) auto auto; gap: 10px; }
.key-note { margin: 10px 0 0; color: #5e6772; font-size: 12px; }
.key-note code { color: #414eb8; }
.result-alert { margin-bottom: 18px; }
@media (max-width: 760px) {
.page-heading { flex-direction: column; }
.form-grid, .protocol-grid, .key-editor { grid-template-columns: 1fr; }
.settings-panel { padding: 18px; }
}
</style>