chore: initialize wygj monorepo

This commit is contained in:
2026-07-03 01:25:10 +08:00
commit 62ffcb6e99
1248 changed files with 137340 additions and 0 deletions
+915
View File
@@ -0,0 +1,915 @@
<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 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>
<div class="tip-bar">
<el-icon><InfoFilled /></el-icon>
<span>调用优先级:模型配置覆盖供应商默认值;未配置时回退 seed,接口不返回 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-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="用于 Demo 对话与 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>
</div>
</template>
<script setup name="SystemModelConfig" lang="ts">
import { Connection, EditPen, Hide, InfoFilled, Plus, Search, View } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
createModelConfig,
createModelProvider,
getModelConfigs,
getModelProviders,
testModelChat,
updateModelConfig,
updateModelConfigEnabled,
updateModelProvider,
updateModelProviderStatus,
type ModelConfig,
type ModelConfigPayload,
type ModelProvider,
type ModelProviderPayload
} from '@/api/aihr/model';
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;
};
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'
}
];
const seedModels: ModelConfig[] = [
{
id: -1,
category: 'chat',
modelName: 'deepseek-chat',
providerCode: 'deepseek',
modelDescribe: '用于 Demo 对话与 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
}
];
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 showKey = ref(false);
const providerDialogVisible = ref(false);
const modelDialogVisible = ref(false);
const lastTestAt = ref('未测试');
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 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.category === 'chat' && item.enabled) ?? modelRows.value[0]);
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 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]));
return items.map((item) => {
const seed = seedMap.get(item.providerCode);
return {
...item,
logo: seed?.logo ?? item.providerName.slice(0, 2).toUpperCase()
};
});
};
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 = configResult.data?.length ? configResult.data : seedModels;
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 不可用,已展示本地 seed 原型数据');
} finally {
loading.value = false;
}
};
const runConnectionTest = async () => {
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('后端返回 seed fallback,请先在数据库配置供应商 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('seed 供应商请先保存后再启停');
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);
ElMessage.success('模型配置已保存');
} finally {
saving.value = false;
}
};
const toggleModelEnabled = async (row: ModelConfig, enabled: boolean) => {
if (row.id < 0) {
row.enabled = !enabled;
ElMessage.warning('seed 模型请先保存后再启停');
openModelDialog(row);
return;
}
try {
await updateModelConfigEnabled(row.id, enabled);
row.enabled = enabled;
ElMessage.success(enabled ? '模型已启用' : '模型已停用');
} catch {
row.enabled = !enabled;
}
};
onMounted(loadData);
</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;
}
.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,
.model-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;
}
.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 {
grid-template-columns: 1fr;
}
.field-list.side {
padding-left: 0;
border-left: 0;
}
}
</style>