Files
prop-ai-hr/frontend/src/views/system/model/index.vue
T

1240 lines
36 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="model-config-page">
<section class="page-head">
<div>
<h1>模型配置</h1>
<p>统一维护供应商 Key、Base URL 与可用模型,供 RAG、面试出题、对练调用。</p>
</div>
<div class="head-actions">
<el-button type="danger" :icon="Plus" @click="openProviderDialog()">新增供应商</el-button>
<el-button type="danger" :icon="Plus" @click="openModelDialog()">新增模型</el-button>
<el-button :icon="Connection" :loading="testing" @click="runConnectionTest">测试连接</el-button>
</div>
</section>
<section class="model-layout">
<aside class="panel provider-panel">
<div class="panel-title">
<h2>供应商</h2>
</div>
<el-input v-model="keyword" class="provider-search" placeholder="搜索供应商名称或编码" :prefix-icon="Search" clearable />
<div v-loading="loading" class="provider-list">
<button
v-for="provider in filteredProviders"
:key="provider.providerCode"
class="provider-item"
:class="{ active: provider.providerCode === selectedProviderCode }"
@click="selectedProviderCode = provider.providerCode"
>
<span class="provider-logo" :class="`logo-${provider.providerCode}`">{{ provider.logo }}</span>
<span class="provider-text">
<strong>{{ provider.providerName }}</strong>
<small>{{ provider.providerCode }}</small>
</span>
<span class="status-tag" :class="providerTag(provider).className">{{ providerTag(provider).label }}</span>
</button>
</div>
<footer>共 {{ providers.length }} 个供应商</footer>
</aside>
<main class="detail-stack">
<section class="panel detail-panel">
<div class="panel-title with-actions">
<h2>供应商详情</h2>
<div>
<el-button :icon="EditPen" @click="openProviderDialog(activeProvider)">编辑</el-button>
<el-button type="danger" plain :icon="Connection" :loading="testing" @click="runConnectionTest">测试</el-button>
</div>
</div>
<div class="detail-grid">
<div class="field-list">
<div class="field-row">
<span>供应商名称</span>
<strong>{{ activeProvider.providerName }}</strong>
</div>
<div class="field-row">
<span>供应商编码</span>
<strong>{{ activeProvider.providerCode }}</strong>
</div>
<div class="field-row">
<span>Base URL</span>
<strong>{{ activeProvider.apiHost }}</strong>
</div>
<div class="field-row">
<span>API Key</span>
<strong>
{{ keyText }}
<el-button link :icon="showKey ? View : Hide" @click="showKey = !showKey" />
</strong>
</div>
</div>
<div class="field-list side">
<div class="field-row">
<span>状态</span>
<el-switch
:model-value="activeProvider.status === '0'"
active-color="#e60012"
inactive-color="#d7dee8"
@change="(value) => toggleProviderStatus(Boolean(value))"
/>
</div>
<div class="field-row">
<span>最近测试时间</span>
<strong>{{ lastTestAt }}</strong>
</div>
<div class="field-row">
<span>请求方式</span>
<strong>OpenAI Compatible</strong>
</div>
<div class="field-row">
<span>备注</span>
<strong>{{ activeProvider.providerDesc || `${activeProvider.providerName} OpenAI 兼容接口` }}</strong>
</div>
</div>
</div>
</section>
<section class="panel vector-panel" v-loading="vectorLoading">
<div class="panel-title with-actions">
<h2>向量库状态</h2>
<div>
<el-button :icon="Refresh" @click="loadVectorStatus">刷新</el-button>
<el-button type="danger" plain :icon="Refresh" :loading="rebuilding" @click="runVectorRebuild">重建索引</el-button>
</div>
</div>
<div class="vector-grid">
<div class="field-row">
<span>启用模型</span>
<strong>{{ vectorStatus?.modelName || '-' }}</strong>
</div>
<div class="field-row">
<span>模型维度</span>
<strong>{{ vectorStatus?.modelDimension || '-' }}</strong>
</div>
<div class="field-row">
<span>Qdrant 维度</span>
<strong>{{ vectorStatus?.qdrantDimension || '-' }}</strong>
</div>
<div class="field-row">
<span>点数</span>
<strong>{{ vectorStatus?.qdrantPoints ?? '-' }}</strong>
</div>
<div class="field-row">
<span>片段向量</span>
<strong>{{ vectorStatus ? `${vectorStatus.embeddedFragments} / ${vectorStatus.fragments}` : '-' }}</strong>
</div>
<div class="field-row">
<span>当前状态</span>
<strong>
<el-tag :type="vectorStatus?.matched ? 'success' : 'warning'">{{ vectorStatusLabel }}</el-tag>
</strong>
</div>
</div>
</section>
<section class="panel model-panel">
<div class="panel-title">
<h2>模型列表</h2>
</div>
<el-table :data="modelRows" class="model-table" size="large">
<el-table-column prop="category" label="类型" width="130" />
<el-table-column prop="modelName" label="模型名" min-width="190" />
<el-table-column prop="modelDescribe" label="用途" min-width="280" />
<el-table-column label="维度" width="110">
<template #default="{ row }">{{ row.modelDimension || '-' }}</template>
</el-table-column>
<el-table-column label="启用" width="110">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
active-color="#e60012"
inactive-color="#d7dee8"
@change="(value) => toggleModelEnabled(row, Boolean(value))"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="110">
<template #default="{ row }">
<el-button link type="danger" @click="openModelDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</section>
<section class="panel prompt-panel" v-loading="promptLoading">
<div class="panel-title with-actions">
<h2>提示词模板</h2>
<div>
<el-button :icon="Refresh" @click="loadPromptTemplates">刷新</el-button>
<el-button type="danger" :icon="Plus" @click="openPromptDialog()">新增模板</el-button>
</div>
</div>
<el-table :data="promptTemplates" class="model-table" size="large">
<el-table-column prop="code" label="代码" min-width="150" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="outputFormat" label="文体" width="120" />
<el-table-column prop="version" label="版本" width="100" />
<el-table-column label="启用" width="100">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
active-color="#e60012"
inactive-color="#d7dee8"
@change="(value) => togglePromptEnabled(row, Boolean(value))"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="danger" @click="openPromptDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</section>
<div class="tip-bar">
<el-icon><InfoFilled /></el-icon>
<span>调用优先级:模型配置覆盖供应商默认值;未配置时使用内置默认兜底,接口不返回 API Key。</span>
</div>
</main>
</section>
<el-dialog v-model="providerDialogVisible" :title="providerForm.id > 0 ? '编辑供应商' : '新增供应商'" width="560px">
<el-form label-width="104px">
<el-form-item label="供应商名称" required>
<el-input v-model="providerForm.providerName" placeholder="DeepSeek" />
</el-form-item>
<el-form-item label="供应商编码" required>
<el-input v-model="providerForm.providerCode" placeholder="deepseek" :disabled="providerForm.id > 0" />
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="providerForm.apiHost" placeholder="https://api.deepseek.com" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="providerForm.apiKey" type="password" show-password placeholder="留空则保留原密钥" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="providerForm.providerDesc" type="textarea" :rows="2" placeholder="OpenAI 兼容接口" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="providerEnabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="providerDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="saving" @click="submitProvider">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="modelDialogVisible" :title="modelForm.id > 0 ? '编辑模型' : '新增模型'" width="560px">
<el-form label-width="104px">
<el-form-item label="模型类型" required>
<el-select v-model="modelForm.category">
<el-option label="对话 chat" value="chat" />
<el-option label="向量 vector" value="vector" />
<el-option label="重排 rerank" value="rerank" />
<el-option label="语音识别 asr" value="asr" />
<el-option label="语音合成 tts" value="tts" />
<el-option label="视觉/OCR vision" value="vision" />
</el-select>
</el-form-item>
<el-form-item label="模型名称" required>
<el-input v-model="modelForm.modelName" placeholder="deepseek-chat" />
</el-form-item>
<el-form-item label="供应商" required>
<el-select v-model="modelForm.providerCode">
<el-option v-for="provider in providers" :key="provider.providerCode" :label="provider.providerName" :value="provider.providerCode" />
</el-select>
</el-form-item>
<el-form-item label="用途">
<el-input v-model="modelForm.modelDescribe" placeholder="用于通用对话与 RAG 生成" />
</el-form-item>
<el-form-item label="维度">
<el-input-number v-model="modelForm.modelDimension" :min="0" :controls="false" placeholder="向量模型填写" />
</el-form-item>
<el-form-item label="覆盖 URL">
<el-input v-model="modelForm.apiHost" placeholder="留空则使用供应商 Base URL" />
</el-form-item>
<el-form-item label="覆盖 Key">
<el-input v-model="modelForm.apiKey" type="password" show-password placeholder="留空则使用供应商 Key 或保留原值" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="modelForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modelDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="saving" @click="submitModel">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="promptDialogVisible" :title="promptForm.id > 0 ? '编辑提示词模板' : '新增提示词模板'" width="720px">
<el-form label-width="104px">
<el-form-item label="模板代码" required>
<el-input v-model="promptForm.code" placeholder="summary_card" />
</el-form-item>
<el-form-item label="模板名称" required>
<el-input v-model="promptForm.name" placeholder="总结卡" />
</el-form-item>
<el-form-item label="分类">
<el-input v-model="promptForm.category" placeholder="practice" />
</el-form-item>
<el-form-item label="输出文体">
<el-input v-model="promptForm.outputFormat" placeholder="summary_card" />
</el-form-item>
<el-form-item label="系统提示词">
<el-input v-model="promptForm.systemPrompt" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="提示词模板" required>
<el-input v-model="promptForm.template" type="textarea" :rows="5" />
</el-form-item>
<el-form-item label="变量">
<el-input v-model="promptForm.variables" placeholder='["question","answer"]' />
</el-form-item>
<el-form-item label="版本">
<el-input v-model="promptForm.version" placeholder="v1" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="promptForm.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="promptForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="promptDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="promptSaving" @click="submitPromptTemplate">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="SystemModelConfig" lang="ts">
import { Connection, EditPen, Hide, InfoFilled, Plus, Refresh, Search, View } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
createModelConfig,
createModelProvider,
getModelConfigs,
getModelProviders,
getVectorIndexStatus,
rebuildVectorIndex,
testModelChat,
updateModelConfig,
updateModelConfigEnabled,
updateModelProvider,
updateModelProviderStatus,
type ModelConfig,
type ModelConfigPayload,
type ModelProvider,
type ModelProviderPayload,
type VectorIndexStatus
} from '@/api/aihr/model';
import {
createPromptTemplate,
listPromptTemplates,
updatePromptTemplate,
type PromptTemplateRequest,
type PromptTemplateResponse
} from '@/api/aihr/practice';
type ProviderView = ModelProvider & { logo: string };
type ProviderFormState = ModelProviderPayload & {
id: number;
originalProviderCode: string;
apiHost: string;
apiKey: string;
providerDesc: string;
sortOrder: number;
};
type ModelFormState = ModelConfigPayload & {
id: number;
apiHost: string;
apiKey: string;
modelDescribe: string;
};
type PromptTemplateFormState = PromptTemplateRequest & {
id: number;
};
const seedProviders: ProviderView[] = [
{
id: -2,
providerName: 'DeepSeek',
providerCode: 'deepseek',
providerDesc: 'DeepSeek 官方 API',
apiHost: 'https://api.deepseek.com',
status: '0',
sortOrder: 0,
source: 'seed',
configured: false,
logo: 'DS'
},
{
id: -3,
providerName: '智谱AI',
providerCode: 'zhipu',
providerDesc: '智谱 AI 大模型服务',
apiHost: 'https://open.bigmodel.cn',
status: '0',
sortOrder: 1,
source: 'seed',
configured: false,
logo: '智'
},
{
id: -4,
providerName: '阿里云百炼',
providerCode: 'qianwen',
providerDesc: '阿里云百炼大模型服务',
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
status: '0',
sortOrder: 2,
source: 'seed',
configured: false,
logo: '百'
},
{
id: -1,
providerName: 'OpenAI兼容接口',
providerCode: 'custom_api',
providerDesc: '自定义 OpenAI 兼容接口',
apiHost: '自定义',
status: '1',
sortOrder: 3,
source: 'seed',
configured: false,
logo: 'AI'
},
{
id: -5,
providerName: 'Ollama(本地)',
providerCode: 'ollama',
providerDesc: '本地 Ollama 服务',
apiHost: 'http://127.0.0.1:11434/v1',
status: '0',
sortOrder: 4,
source: 'seed',
configured: false,
logo: 'OL'
},
{
id: -6,
providerName: '硅基流动',
providerCode: 'siliconflow',
providerDesc: '硅基流动 OpenAI 兼容接口',
apiHost: 'https://api.siliconflow.cn/v1',
status: '0',
sortOrder: 5,
source: 'seed',
configured: false,
logo: 'SF'
}
];
const seedModels: ModelConfig[] = [
{
id: -1,
category: 'chat',
modelName: 'deepseek-chat',
providerCode: 'deepseek',
modelDescribe: '用于通用对话与 RAG 生成',
modelShow: 'Y',
apiHost: '',
enabled: true,
apiKeyConfigured: false,
configured: true
},
{
id: -2,
category: 'chat',
modelName: 'deepseek-reasoner',
providerCode: 'deepseek',
modelDescribe: '用于复杂评分解释',
modelShow: 'Y',
apiHost: '',
enabled: true,
apiKeyConfigured: false,
configured: true
},
{
id: -3,
category: 'vector',
modelName: 'embedding-3',
providerCode: 'zhipu',
modelDescribe: '用于知识片段量化',
modelDimension: 2048,
modelShow: 'N',
apiHost: '',
enabled: false,
apiKeyConfigured: false,
configured: false
},
{
id: -4,
category: 'rerank',
modelName: 'rerank',
providerCode: 'zhipu',
modelDescribe: '用于召回片段重排',
modelShow: 'N',
apiHost: '',
enabled: false,
apiKeyConfigured: false,
configured: false
},
{
id: -5,
category: 'vector',
modelName: 'BAAI/bge-m3',
providerCode: 'siliconflow',
modelDescribe: '硅基流动 bge-m3,用于知识片段向量化',
modelDimension: 1024,
modelShow: 'N',
apiHost: '',
enabled: false,
apiKeyConfigured: false,
configured: false
}
];
const providers = ref<ProviderView[]>(seedProviders);
const modelRows = ref<ModelConfig[]>(seedModels);
const selectedProviderCode = ref('deepseek');
const keyword = ref('');
const loading = ref(false);
const testing = ref(false);
const saving = ref(false);
const promptLoading = ref(false);
const promptSaving = ref(false);
const vectorLoading = ref(false);
const rebuilding = ref(false);
const showKey = ref(false);
const providerDialogVisible = ref(false);
const modelDialogVisible = ref(false);
const promptDialogVisible = ref(false);
const lastTestAt = ref('未测试');
const vectorStatus = ref<VectorIndexStatus>();
const promptTemplates = ref<PromptTemplateResponse[]>([]);
const providerForm = reactive<ProviderFormState>({
id: 0,
originalProviderCode: '',
providerName: '',
providerCode: '',
providerDesc: '',
apiHost: '',
apiKey: '',
status: '0',
sortOrder: 0
});
const modelForm = reactive<ModelFormState>({
id: 0,
category: 'chat',
modelName: '',
providerCode: '',
modelDescribe: '',
modelDimension: undefined,
modelShow: 'Y',
apiHost: '',
apiKey: '',
enabled: true
});
const promptForm = reactive<PromptTemplateFormState>({
id: 0,
code: '',
name: '',
category: 'practice',
outputFormat: '',
systemPrompt: '',
template: '',
variables: '',
version: 'v1',
enabled: true,
description: ''
});
const filteredProviders = computed(() => {
const word = keyword.value.trim().toLowerCase();
if (!word) return providers.value;
return providers.value.filter((item) => `${item.providerName} ${item.providerCode}`.toLowerCase().includes(word));
});
const activeProvider = computed<ProviderView>(
() => providers.value.find((item) => item.providerCode === selectedProviderCode.value) ?? providers.value[0] ?? seedProviders[0]
);
const activeChatModel = computed(() =>
modelRows.value.find(
(item) => item.id > 0 && item.providerCode === selectedProviderCode.value && item.category === 'chat' && item.enabled && item.configured
)
);
const keyText = computed(() => {
if (!activeProvider.value.configured) return '未配置';
return showKey.value ? '已配置,接口不回传密钥' : 'sk-************';
});
const providerEnabled = computed({
get: () => providerForm.status === '0',
set: (value: boolean) => {
providerForm.status = value ? '0' : '1';
}
});
const vectorStatusLabel = computed(() => (vectorStatus.value?.matched ? '一致' : vectorStatus.value?.message || '需重建'));
const providerTag = (provider: ProviderView) => {
if (provider.providerCode.includes('ollama')) return { label: '本地', className: 'neutral' };
if (provider.status !== '0') return { label: '未启用', className: 'warning' };
if (!provider.configured) return { label: '未配置', className: 'warning' };
return { label: '已启用', className: 'success' };
};
const normalizeProviders = (items: ModelProvider[]): ProviderView[] => {
if (!items.length) return seedProviders;
const seedMap = new Map(seedProviders.map((item) => [item.providerCode, item]));
const rows = items.map((item) => {
const seed = seedMap.get(item.providerCode);
return {
...item,
logo: seed?.logo ?? item.providerName.slice(0, 2).toUpperCase()
};
});
const existingCodes = new Set(rows.map((item) => item.providerCode));
return [...rows, ...seedProviders.filter((item) => !existingCodes.has(item.providerCode))];
};
const modelKey = (item: ModelConfig) => `${item.category}:${item.providerCode}:${item.modelName}`;
const normalizeModels = (items: ModelConfig[]): ModelConfig[] => {
if (!items.length) return seedModels;
const existingKeys = new Set(items.map(modelKey));
return [...items, ...seedModels.filter((item) => !existingKeys.has(modelKey(item)))];
};
const loadData = async (preferredProviderCode = selectedProviderCode.value) => {
loading.value = true;
try {
const [providerResult, configResult] = await Promise.all([getModelProviders(), getModelConfigs()]);
providers.value = normalizeProviders(providerResult.data ?? []);
modelRows.value = normalizeModels(configResult.data ?? []);
selectedProviderCode.value =
providers.value.find((item) => item.providerCode === preferredProviderCode)?.providerCode ?? providers.value[0]?.providerCode ?? 'deepseek';
} catch {
providers.value = seedProviders;
modelRows.value = seedModels;
selectedProviderCode.value = preferredProviderCode || 'deepseek';
ElMessage.warning('模型配置 API 不可用,已展示内置默认清单');
} finally {
loading.value = false;
}
};
const loadVectorStatus = async () => {
vectorLoading.value = true;
try {
const result = await getVectorIndexStatus();
vectorStatus.value = result.data;
} catch {
vectorStatus.value = undefined;
} finally {
vectorLoading.value = false;
}
};
const loadPromptTemplates = async () => {
promptLoading.value = true;
try {
const result = await listPromptTemplates();
promptTemplates.value = result.data ?? [];
} catch {
promptTemplates.value = [];
} finally {
promptLoading.value = false;
}
};
const runVectorRebuild = async () => {
try {
await ElMessageBox.confirm('将清空旧片段向量并重建 Qdrant 索引,继续吗?', '重建向量索引', {
confirmButtonText: '重建',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
rebuilding.value = true;
try {
const result = await rebuildVectorIndex();
await loadVectorStatus();
ElMessage.success(`已重建 ${result.data.documents} 份资料 / ${result.data.fragments} 个片段`);
} finally {
rebuilding.value = false;
}
};
const runConnectionTest = async () => {
if (!activeChatModel.value) {
ElMessage.warning('请先为当前供应商配置并启用 chat 模型');
return;
}
testing.value = true;
try {
const result = await testModelChat({
model: activeChatModel.value?.modelName,
prompt: '请用一句话说明模型配置已连接。'
});
lastTestAt.value = new Date().toLocaleString('zh-CN', { hour12: false });
if (result.data?.configured) {
ElMessage.success(`连接成功:${result.data.modelName}`);
} else {
ElMessage.warning('当前模型未完成真实调用配置,请检查供应商 URL、Key 和模型名');
}
} catch {
ElMessage.warning('模型测试接口不可用,请检查后端服务、登录状态或当前供应商配置');
} finally {
testing.value = false;
}
};
const openProviderDialog = (provider?: ProviderView) => {
const source = provider ?? undefined;
Object.assign(providerForm, {
id: source && source.id > 0 ? source.id : 0,
originalProviderCode: source && source.id > 0 ? source.providerCode : '',
providerName: source?.providerName ?? '',
providerCode: source?.providerCode ?? '',
providerDesc: source?.providerDesc ?? '',
apiHost: source?.apiHost === '自定义' ? '' : (source?.apiHost ?? ''),
apiKey: '',
status: source?.status ?? '0',
sortOrder: source?.sortOrder ?? providers.value.length
});
providerDialogVisible.value = true;
};
const providerPayload = (): ModelProviderPayload | undefined => {
const providerName = providerForm.providerName.trim();
const providerCode = providerForm.providerCode.trim();
if (!providerName || !providerCode) {
ElMessage.warning('请填写供应商名称和编码');
return undefined;
}
return {
providerName,
providerCode,
providerDesc: providerForm.providerDesc.trim(),
apiHost: providerForm.apiHost.trim(),
apiKey: providerForm.apiKey.trim(),
status: providerForm.status,
sortOrder: providerForm.sortOrder
};
};
const submitProvider = async () => {
const payload = providerPayload();
if (!payload) return;
saving.value = true;
try {
if (providerForm.id > 0) {
await updateModelProvider(providerForm.originalProviderCode || providerForm.providerCode, payload);
} else {
await createModelProvider(payload);
}
providerDialogVisible.value = false;
await loadData(payload.providerCode);
ElMessage.success('供应商配置已保存');
} finally {
saving.value = false;
}
};
const toggleProviderStatus = async (enabled: boolean) => {
const provider = activeProvider.value;
if (provider.id < 0) {
ElMessage.warning('内置供应商请先保存后再启停');
openProviderDialog(provider);
return;
}
const status = enabled ? '0' : '1';
try {
await updateModelProviderStatus(provider.providerCode, status);
await loadData(provider.providerCode);
ElMessage.success(enabled ? '供应商已启用' : '供应商已停用');
} catch {
await loadData(provider.providerCode);
}
};
const openModelDialog = (model?: ModelConfig) => {
Object.assign(modelForm, {
id: model && model.id > 0 ? model.id : 0,
category: model?.category ?? 'chat',
modelName: model?.modelName ?? '',
providerCode: model?.providerCode ?? selectedProviderCode.value,
modelDescribe: model?.modelDescribe ?? '',
modelDimension: model?.modelDimension,
modelShow: model?.modelShow ?? 'Y',
apiHost: model?.apiHost ?? '',
apiKey: '',
enabled: model?.enabled ?? true
});
modelDialogVisible.value = true;
};
const modelPayload = (): ModelConfigPayload | undefined => {
const modelName = modelForm.modelName.trim();
const providerCode = modelForm.providerCode.trim();
if (!modelName || !providerCode) {
ElMessage.warning('请填写模型名称和供应商');
return undefined;
}
return {
category: modelForm.category,
modelName,
providerCode,
modelDescribe: modelForm.modelDescribe.trim(),
modelDimension: modelForm.modelDimension && modelForm.modelDimension > 0 ? modelForm.modelDimension : undefined,
modelShow: modelForm.modelShow,
apiHost: modelForm.apiHost.trim(),
apiKey: modelForm.apiKey.trim(),
enabled: modelForm.enabled
};
};
const submitModel = async () => {
const payload = modelPayload();
if (!payload) return;
saving.value = true;
try {
if (modelForm.id > 0) {
await updateModelConfig(modelForm.id, payload);
} else {
await createModelConfig(payload);
}
modelDialogVisible.value = false;
await loadData(payload.providerCode);
await loadVectorStatus();
ElMessage.success('模型配置已保存');
} finally {
saving.value = false;
}
};
const toggleModelEnabled = async (row: ModelConfig, enabled: boolean) => {
if (row.id < 0) {
row.enabled = !enabled;
ElMessage.warning('内置模型请先保存后再启停');
openModelDialog(row);
return;
}
try {
await updateModelConfigEnabled(row.id, enabled);
row.enabled = enabled;
await loadVectorStatus();
ElMessage.success(enabled ? '模型已启用' : '模型已停用');
} catch {
row.enabled = !enabled;
}
};
const openPromptDialog = (template?: PromptTemplateResponse) => {
Object.assign(promptForm, {
id: template?.id ?? 0,
code: template?.code ?? '',
name: template?.name ?? '',
category: template?.category ?? 'practice',
outputFormat: template?.outputFormat ?? '',
systemPrompt: template?.systemPrompt ?? '',
template: template?.template ?? '',
variables: template?.variables ?? '',
version: template?.version ?? 'v1',
enabled: template?.enabled ?? true,
description: template?.description ?? ''
});
promptDialogVisible.value = true;
};
const promptPayload = (): PromptTemplateRequest | undefined => {
const code = promptForm.code.trim();
const name = promptForm.name.trim();
const template = promptForm.template.trim();
if (!code || !name || !template) {
ElMessage.warning('请填写模板代码、名称和提示词模板');
return undefined;
}
return {
code,
name,
category: promptForm.category.trim() || 'practice',
outputFormat: promptForm.outputFormat.trim() || code,
systemPrompt: promptForm.systemPrompt.trim(),
template,
variables: promptForm.variables.trim(),
version: promptForm.version.trim() || 'v1',
enabled: promptForm.enabled,
description: promptForm.description.trim()
};
};
const submitPromptTemplate = async () => {
const payload = promptPayload();
if (!payload) return;
promptSaving.value = true;
try {
if (promptForm.id > 0) {
await updatePromptTemplate(promptForm.id, payload);
} else {
await createPromptTemplate(payload);
}
promptDialogVisible.value = false;
await loadPromptTemplates();
ElMessage.success('提示词模板已保存');
} finally {
promptSaving.value = false;
}
};
const togglePromptEnabled = async (row: PromptTemplateResponse, enabled: boolean) => {
try {
await updatePromptTemplate(row.id, { ...row, enabled });
row.enabled = enabled;
ElMessage.success(enabled ? '模板已启用' : '模板已停用');
} catch {
row.enabled = !enabled;
}
};
onMounted(() => {
loadData();
loadVectorStatus();
loadPromptTemplates();
});
</script>
<style lang="scss" scoped>
.model-config-page {
min-height: calc(100vh - 84px);
padding: 28px 32px 34px;
background: #f4f7fb;
color: #172033;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 24px;
h1 {
margin: 0 0 12px;
font-size: 30px;
line-height: 1.2;
font-weight: 800;
}
p {
margin: 0;
color: #5f6f86;
font-size: 15px;
}
}
.head-actions {
display: flex;
align-items: center;
gap: 12px;
padding-top: 2px;
white-space: nowrap;
}
.model-layout {
display: grid;
grid-template-columns: minmax(280px, 330px) minmax(0, 1fr);
gap: 20px;
}
.panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 10px 24px rgb(15 23 42 / 5%);
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 24px 14px;
h2 {
margin: 0;
font-size: 20px;
line-height: 1.2;
font-weight: 800;
}
}
.provider-panel {
overflow: hidden;
footer {
padding: 18px 24px 22px;
color: #64748b;
border-top: 1px solid #edf1f6;
}
}
.provider-search {
width: calc(100% - 40px);
margin: 4px 20px 18px;
}
.provider-list {
min-height: 420px;
}
.provider-item {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: 82px;
padding: 14px 22px;
border: 0;
border-top: 1px solid #edf1f6;
border-left: 4px solid transparent;
background: #fff;
color: #172033;
text-align: left;
cursor: pointer;
&.active {
border-left-color: #e60012;
background: linear-gradient(90deg, #fff1f2 0%, #fff 100%);
}
}
.provider-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: #eef4ff;
color: #2264d1;
font-size: 13px;
font-weight: 800;
}
.logo-zhipu {
background: #f3e8ff;
color: #7c3aed;
}
.logo-qianwen {
background: #e0f2fe;
color: #0891b2;
}
.logo-custom_api {
background: #f1f5f9;
color: #111827;
}
.logo-ollama {
background: #f8fafc;
color: #334155;
}
.logo-siliconflow {
background: #f3e8ff;
color: #6d28d9;
}
.provider-text {
min-width: 0;
strong,
small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
font-size: 16px;
font-weight: 800;
}
small {
margin-top: 5px;
color: #64748b;
font-size: 13px;
}
}
.status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 28px;
padding: 0 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 700;
&.success {
color: #17834d;
background: #dcfce7;
}
&.warning {
color: #c2410c;
background: #ffedd5;
}
&.neutral {
color: #475569;
background: #eef2f7;
}
}
.detail-stack {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.detail-panel,
.vector-panel,
.model-panel,
.prompt-panel {
padding-bottom: 18px;
}
.with-actions {
padding-bottom: 18px;
border-bottom: 1px solid #edf1f6;
> div {
display: flex;
gap: 10px;
}
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.72fr);
gap: 34px;
padding: 22px 24px 6px;
}
.vector-grid {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 18px 24px;
padding: 22px 24px 6px;
}
.field-list {
display: grid;
gap: 20px;
&.side {
padding-left: 32px;
border-left: 1px solid #edf1f6;
}
}
.field-row {
display: grid;
grid-template-columns: 116px minmax(0, 1fr);
align-items: center;
gap: 16px;
span {
color: #64748b;
font-size: 15px;
}
strong {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #25324a;
font-size: 15px;
font-weight: 500;
word-break: break-all;
}
}
.model-table {
width: calc(100% - 40px);
margin: 4px 20px 0;
border: 1px solid #edf1f6;
border-radius: 8px;
overflow: hidden;
}
.tip-bar {
display: flex;
align-items: center;
gap: 12px;
min-height: 54px;
padding: 0 18px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff7f7;
color: #8a2f33;
}
:deep(.el-button--danger) {
--el-button-bg-color: #e60012;
--el-button-border-color: #e60012;
--el-button-hover-bg-color: #f0202f;
--el-button-hover-border-color: #f0202f;
}
:deep(.el-table th.el-table__cell) {
background: #f8fafc;
color: #334155;
font-weight: 800;
}
@media (max-width: 1100px) {
.page-head,
.head-actions {
flex-wrap: wrap;
}
.model-layout,
.detail-grid,
.vector-grid {
grid-template-columns: 1fr;
}
.field-list.side {
padding-left: 0;
border-left: 0;
}
}
</style>