feat(frontend): 新增模型平台管理页面并接入RAG模型配置

This commit is contained in:
2026-05-27 22:14:34 +08:00
parent 5d7ca5b31f
commit 21c9eaa44d
12 changed files with 1489 additions and 17 deletions

View File

@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkModelProviderHealth,
deleteModelConfig,
deleteModelProvider,
deleteModelRouteRule,
getRagStoreModelConfig,
queryModelCallLogs,
queryModelConfigs,
queryModelProviders,
queryModelRouteRules,
rebuildRagStoreIndex,
saveModelConfig,
saveModelProvider,
saveModelRouteRule,
saveRagStoreModelConfig,
} from '../modelProvider';
import { get, post } from '../request';
vi.mock('../request', () => ({
get: vi.fn(),
post: vi.fn(),
}));
describe('model provider api', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls model platform endpoints with expected params', () => {
queryModelProviders();
saveModelProvider({ providerCode: 'OPENAI_MAIN' } as never);
deleteModelProvider('1');
checkModelProviderHealth('1');
queryModelConfigs();
saveModelConfig({ modelCode: 'EMB_1' } as never);
deleteModelConfig('2');
queryModelRouteRules();
saveModelRouteRule({ routeCode: 'RAG_GLOBAL' } as never);
deleteModelRouteRule('3');
queryModelCallLogs({ taskType: 'RAG_EMBEDDING' });
getRagStoreModelConfig('10');
saveRagStoreModelConfig({ storeId: '10', embeddingModelId: '2' } as never);
rebuildRagStoreIndex('10');
expect(post).toHaveBeenCalledWith('/model/providers/query');
expect(post).toHaveBeenCalledWith('/model/providers/save', { providerCode: 'OPENAI_MAIN' });
expect(post).toHaveBeenCalledWith('/model/providers/delete', undefined, { params: { id: '1' } });
expect(post).toHaveBeenCalledWith('/model/providers/checkHealth', undefined, { params: { id: '1' } });
expect(post).toHaveBeenCalledWith('/model/configs/query');
expect(post).toHaveBeenCalledWith('/model/configs/save', { modelCode: 'EMB_1' });
expect(post).toHaveBeenCalledWith('/model/configs/delete', undefined, { params: { id: '2' } });
expect(post).toHaveBeenCalledWith('/model/routes/query');
expect(post).toHaveBeenCalledWith('/model/routes/save', { routeCode: 'RAG_GLOBAL' });
expect(post).toHaveBeenCalledWith('/model/routes/delete', undefined, { params: { id: '3' } });
expect(post).toHaveBeenCalledWith('/model/call-logs/query', { taskType: 'RAG_EMBEDDING' });
expect(get).toHaveBeenCalledWith('/rag/store/modelConfig', { params: { storeId: '10' } });
expect(post).toHaveBeenCalledWith('/rag/store/modelConfig/save', { storeId: '10', embeddingModelId: '2' });
expect(post).toHaveBeenCalledWith('/rag/store/rebuildIndex', undefined, { params: { storeId: '10' } });
});
});

View File

@@ -0,0 +1,52 @@
import { listForManagement, type SysEnum } from './sysEnums';
export interface EnumOption {
label: string;
value: string;
}
const enumCache = new Map<string, EnumOption[]>();
function buildCacheKey(catalog: string, type: string) {
return `${catalog}::${type}`;
}
function mapOption(item: SysEnum): EnumOption {
const value = item.strvalue && item.strvalue.trim()
? item.strvalue
: item.name;
return {
label: item.name,
value,
};
}
export async function loadEnumOptions(catalog: string, type: string, forceRefresh = false) {
const cacheKey = buildCacheKey(catalog, type);
if (!forceRefresh && enumCache.has(cacheKey)) {
return enumCache.get(cacheKey) ?? [];
}
const response = await listForManagement({ catalog, type, keyword: '' });
const options = (response.data ?? []).map(mapOption);
enumCache.set(cacheKey, options);
return options;
}
export async function loadModelProviderEnumOptions(forceRefresh = false) {
const catalog = 'model_provider';
const types = [
'provider_type',
'protocol_type',
'auth_type',
'model_type',
'task_type',
'route_strategy',
'health_status',
'call_status',
'match_scope',
];
const entries = await Promise.all(
types.map(async (type) => [type, await loadEnumOptions(catalog, type, forceRefresh)] as const),
);
return Object.fromEntries(entries) as Record<string, EnumOption[]>;
}

View File

@@ -0,0 +1,141 @@
import { get, post } from './request';
export interface ModelProvider {
id?: string;
providerCode: string;
providerName: string;
providerType: string;
protocolType: string;
baseUrl: string;
authType: string;
secretRef?: string;
hasApiKey?: boolean;
timeoutMs: number;
priority: number;
enabled: boolean;
healthStatus?: string;
remark?: string;
}
export interface ModelConfig {
id?: string;
providerId: string;
modelCode: string;
modelName: string;
upstreamModel: string;
modelType: string;
embeddingDimension?: number;
localModel: boolean;
defaultModel: boolean;
optionsJson?: string;
enabled: boolean;
remark?: string;
}
export interface ModelRouteRule {
id?: string;
routeCode: string;
routeName: string;
taskType: string;
matchScope: string;
scopeId?: string;
primaryModelId: string;
fallbackModelIdsJson?: string;
routeStrategy: string;
maxLatencyMs?: number;
enabled: boolean;
remark?: string;
}
export interface ModelCallLog {
id?: string;
requestId: string;
providerId?: string;
modelId?: string;
taskType: string;
bizType?: string;
bizId?: string;
callType: string;
status: string;
durationMs?: number;
errorCode?: string;
errorMessage?: string;
}
export interface ModelCallLogQueryRequest {
taskType?: string;
providerId?: string;
modelId?: string;
status?: string;
bizType?: string;
}
export interface RagStoreModelConfig {
id?: string;
storeId: string;
embeddingModelId: string;
embeddingDimension: number;
chunkStrategy?: number;
chunkSize?: number;
chunkOverlap?: number;
delimiter?: string;
active?: boolean;
indexVersion?: number;
remark?: string;
}
export function queryModelProviders() {
return post<ModelProvider[]>('/model/providers/query');
}
export function saveModelProvider(data: Partial<ModelProvider> & { id?: string }) {
return post<boolean>('/model/providers/save', data);
}
export function deleteModelProvider(id: string) {
return post<boolean>('/model/providers/delete', undefined, { params: { id } });
}
export function checkModelProviderHealth(id: string) {
return post<boolean>('/model/providers/checkHealth', undefined, { params: { id } });
}
export function queryModelConfigs() {
return post<ModelConfig[]>('/model/configs/query');
}
export function saveModelConfig(data: Partial<ModelConfig> & { id?: string }) {
return post<boolean>('/model/configs/save', data);
}
export function deleteModelConfig(id: string) {
return post<boolean>('/model/configs/delete', undefined, { params: { id } });
}
export function queryModelRouteRules() {
return post<ModelRouteRule[]>('/model/routes/query');
}
export function saveModelRouteRule(data: Partial<ModelRouteRule> & { id?: string }) {
return post<boolean>('/model/routes/save', data);
}
export function deleteModelRouteRule(id: string) {
return post<boolean>('/model/routes/delete', undefined, { params: { id } });
}
export function queryModelCallLogs(query?: ModelCallLogQueryRequest) {
return post<ModelCallLog[], ModelCallLogQueryRequest | undefined>('/model/call-logs/query', query);
}
export function getRagStoreModelConfig(storeId: string) {
return get<RagStoreModelConfig>('/rag/store/modelConfig', { params: { storeId } });
}
export function saveRagStoreModelConfig(data: Partial<RagStoreModelConfig> & { storeId: string }) {
return post<boolean>('/rag/store/modelConfig/save', data);
}
export function rebuildRagStoreIndex(storeId: string) {
return post<boolean>('/rag/store/rebuildIndex', undefined, { params: { storeId } });
}

View File

@@ -6,10 +6,18 @@ import {
Grid, Grid,
Histogram, Histogram,
List, List,
Setting,
} from '@element-plus/icons-vue'; } from '@element-plus/icons-vue';
const menuItems = [ const systemMenuItems = [
{ path: '/system/enums', label: '系统枚举', icon: Grid }, { path: '/system/enums', label: '系统枚举', icon: Grid },
{ path: '/system/model/providers', label: '模型服务商', icon: Setting },
{ path: '/system/model/configs', label: '模型配置', icon: Setting },
{ path: '/system/model/routes', label: '路由规则', icon: Setting },
{ path: '/system/model/call-logs', label: '调用日志', icon: Setting },
];
const ragMenuItems = [
{ path: '/rag/stores', label: '知识库', icon: Collection }, { path: '/rag/stores', label: '知识库', icon: Collection },
{ path: '/rag/workbench', label: 'RAG工作台', icon: Histogram }, { path: '/rag/workbench', label: 'RAG工作台', icon: Histogram },
{ path: '/rag/documents', label: '知识文档', icon: Document }, { path: '/rag/documents', label: '知识文档', icon: Document },
@@ -28,12 +36,24 @@ const menuItems = [
</div> </div>
<el-menu class="side-menu" :default-active="$route.path" router> <el-menu class="side-menu" :default-active="$route.path" router>
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path"> <el-sub-menu index="system">
<template #title>系统管理</template>
<el-menu-item v-for="item in systemMenuItems" :key="item.path" :index="item.path">
<el-icon> <el-icon>
<component :is="item.icon" /> <component :is="item.icon" />
</el-icon> </el-icon>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</el-menu-item> </el-menu-item>
</el-sub-menu>
<el-sub-menu index="rag">
<template #title>RAG</template>
<el-menu-item v-for="item in ragMenuItems" :key="item.path" :index="item.path">
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-sub-menu>
</el-menu> </el-menu>
</el-aside> </el-aside>

View File

@@ -3,6 +3,14 @@ import { CirclePlus, Delete, Edit, FolderAdd, Refresh, Search, UploadFilled } fr
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
import {
getRagStoreModelConfig,
queryModelConfigs,
rebuildRagStoreIndex,
saveRagStoreModelConfig,
type ModelConfig,
} from '@/api/modelProvider';
import { import {
deleteRagStore, deleteRagStore,
@@ -28,6 +36,20 @@ const activeStoreId = ref<string | null>(null);
const activeStore = ref<RagStore | null>(null); const activeStore = ref<RagStore | null>(null);
const pageOverview = ref<RagStoreOverview | null>(null); const pageOverview = ref<RagStoreOverview | null>(null);
const activeStoreDocumentOverview = ref<RagStoreDocumentOverview | null>(null); const activeStoreDocumentOverview = ref<RagStoreDocumentOverview | null>(null);
const embeddingModels = ref<ModelConfig[]>([]);
const ragConfigLoading = ref(false);
const ragConfigSaving = ref(false);
const ragConfig = reactive({
id: '',
embeddingModelId: '',
embeddingDimension: 1024,
chunkStrategy: null as number | null,
chunkSize: null as number | null,
chunkOverlap: null as number | null,
delimiter: '',
remark: '',
});
const chunkStrategyOptions = ref<EnumOption[]>([]);
const queryForm = reactive({ const queryForm = reactive({
storeName: '', storeName: '',
@@ -91,6 +113,8 @@ async function loadStores(preferredStoreId?: string | null) {
activeStoreId.value = null; activeStoreId.value = null;
activeStore.value = null; activeStore.value = null;
activeStoreDocumentOverview.value = null; activeStoreDocumentOverview.value = null;
ragConfig.id = '';
ragConfig.embeddingModelId = '';
return; return;
} }
@@ -123,6 +147,7 @@ async function selectStore(storeId: string) {
]); ]);
activeStore.value = storeResponse.data ?? null; activeStore.value = storeResponse.data ?? null;
activeStoreDocumentOverview.value = documentOverviewResponse.data ?? null; activeStoreDocumentOverview.value = documentOverviewResponse.data ?? null;
await loadRagModelConfig(storeId);
} finally { } finally {
detailLoading.value = false; detailLoading.value = false;
} }
@@ -223,10 +248,6 @@ async function removeStore() {
await loadStores(); await loadStores();
} }
function showFutureMessage(actionName: string) {
ElMessage.info(`${actionName} 会在下一批接口里补齐`);
}
function openBatchUploadDialog() { function openBatchUploadDialog() {
if (!activeStore.value?.id) { if (!activeStore.value?.id) {
ElMessage.warning('请选择知识库'); ElMessage.warning('请选择知识库');
@@ -252,13 +273,85 @@ async function refreshAfterUpload() {
]); ]);
} }
async function loadRagModelConfig(storeId: string) {
ragConfigLoading.value = true;
try {
const response = await getRagStoreModelConfig(storeId);
const data = response.data;
ragConfig.id = data?.id ?? '';
ragConfig.embeddingModelId = data?.embeddingModelId ?? '';
ragConfig.embeddingDimension = data?.embeddingDimension ?? 1024;
ragConfig.chunkStrategy = data?.chunkStrategy ?? null;
ragConfig.chunkSize = data?.chunkSize ?? null;
ragConfig.chunkOverlap = data?.chunkOverlap ?? null;
ragConfig.delimiter = data?.delimiter ?? '';
ragConfig.remark = data?.remark ?? '';
} finally {
ragConfigLoading.value = false;
}
}
async function saveStoreModelConfig() {
if (!activeStoreId.value) {
return;
}
if (!ragConfig.embeddingModelId) {
ElMessage.warning('请选择 Embedding 模型');
return;
}
ragConfigSaving.value = true;
try {
await saveRagStoreModelConfig({
id: ragConfig.id || undefined,
storeId: activeStoreId.value,
embeddingModelId: ragConfig.embeddingModelId,
embeddingDimension: ragConfig.embeddingDimension,
chunkStrategy: ragConfig.chunkStrategy ?? undefined,
chunkSize: ragConfig.chunkSize ?? undefined,
chunkOverlap: ragConfig.chunkOverlap ?? undefined,
delimiter: ragConfig.delimiter || undefined,
remark: ragConfig.remark || undefined,
});
ElMessage.success('知识库模型配置已保存');
await loadRagModelConfig(activeStoreId.value);
} finally {
ragConfigSaving.value = false;
}
}
async function triggerRebuildIndex() {
if (!activeStoreId.value) {
return;
}
await rebuildRagStoreIndex(activeStoreId.value);
ElMessage.success('已触发重建索引');
}
function syncEmbeddingDimensionFromModel() {
const model = embeddingModels.value.find((item) => item.id === ragConfig.embeddingModelId);
if (!model) {
return;
}
if (model.embeddingDimension) {
ragConfig.embeddingDimension = model.embeddingDimension;
}
}
function getStatusTagType(status?: string | null) { function getStatusTagType(status?: string | null) {
return status === '启用' ? 'success' : 'info'; return status === '启用' ? 'success' : 'info';
} }
onMounted(() => { onMounted(() => {
loadOverview(); Promise.all([
loadStores(); loadOverview(),
loadStores(),
queryModelConfigs().then((response) => {
embeddingModels.value = (response.data ?? []).filter((item) => item.modelType === 'EMBEDDING');
}),
loadModelProviderEnumOptions().then((enums) => {
chunkStrategyOptions.value = enums.chunk_strategy ?? [];
}),
]);
}); });
</script> </script>
@@ -351,7 +444,7 @@ onMounted(() => {
> >
批量导入文件 批量导入文件
</el-button> </el-button>
<el-button :icon="FolderAdd" @click="showFutureMessage('重建索引')">重建索引</el-button> <el-button :icon="FolderAdd" @click="triggerRebuildIndex">重建索引</el-button>
<el-button type="danger" :icon="Delete" @click="removeStore">删除</el-button> <el-button type="danger" :icon="Delete" @click="removeStore">删除</el-button>
</div> </div>
</div> </div>
@@ -415,12 +508,56 @@ onMounted(() => {
</el-descriptions> </el-descriptions>
</article> </article>
<article class="detail-card detail-card--placeholder"> <article class="detail-card">
<div class="detail-card__header"> <div class="detail-card__header">
<h4>检索配置</h4> <h4>检索配置</h4>
<span>下一批接口补充</span> <el-button type="primary" link :loading="ragConfigSaving" @click="saveStoreModelConfig">
保存配置
</el-button>
</div>
<div v-loading="ragConfigLoading" class="rag-config-form">
<el-form :model="ragConfig" label-width="112px">
<el-form-item label="Embedding 模型" required>
<el-select
v-model="ragConfig.embeddingModelId"
placeholder="请选择模型"
@change="syncEmbeddingDimensionFromModel"
>
<el-option
v-for="item in embeddingModels"
:key="item.id"
:label="`${item.modelName}(${item.modelCode})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="向量维度">
<el-input-number v-model="ragConfig.embeddingDimension" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="切片策略">
<el-select v-model="ragConfig.chunkStrategy" clearable placeholder="可选">
<el-option
v-for="item in chunkStrategyOptions"
:key="item.value"
:label="item.label"
:value="Number(item.value)"
/>
</el-select>
</el-form-item>
<el-form-item label="切片大小">
<el-input-number v-model="ragConfig.chunkSize" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="切片重叠">
<el-input-number v-model="ragConfig.chunkOverlap" :min="0" controls-position="right" />
</el-form-item>
<el-form-item label="分隔符">
<el-input v-model="ragConfig.delimiter" placeholder="可选,如 \\n\\n" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="ragConfig.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
</div> </div>
<el-empty description="检索模式、Embedding 模型、Chunk 参数待后端补充" />
</article> </article>
<article class="detail-card detail-card--placeholder"> <article class="detail-card detail-card--placeholder">
@@ -721,6 +858,11 @@ onMounted(() => {
padding: 12px 0; padding: 12px 0;
} }
.rag-config-form :deep(.el-select),
.rag-config-form :deep(.el-input-number) {
width: 100%;
}
@media (max-width: 1280px) { @media (max-width: 1280px) {
.overview-grid { .overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -10,6 +10,12 @@ import {
queryRagStores, queryRagStores,
saveRagStore, saveRagStore,
} from '@/api/ragStores'; } from '@/api/ragStores';
import {
getRagStoreModelConfig,
queryModelConfigs,
rebuildRagStoreIndex,
saveRagStoreModelConfig,
} from '@/api/modelProvider';
const routerPush = vi.hoisted(() => vi.fn()); const routerPush = vi.hoisted(() => vi.fn());
@@ -24,6 +30,52 @@ vi.mock('@/api/ragDocuments', () => ({
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })), batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
})); }));
vi.mock('@/api/modelEnums', () => ({
loadModelProviderEnumOptions: vi.fn(() =>
Promise.resolve({
chunk_strategy: [
{ label: '固定长度', value: '1' },
],
}),
),
}));
vi.mock('@/api/modelProvider', () => ({
queryModelConfigs: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: [
{
id: '88',
providerId: '1',
modelCode: 'TEXT_EMBED_3_LARGE',
modelName: 'text-embedding-3-large',
modelType: 'EMBEDDING',
embeddingDimension: 1024,
localModel: false,
defaultModel: true,
enabled: true,
},
],
}),
),
getRagStoreModelConfig: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: {
id: '9',
storeId: '1',
embeddingModelId: '88',
embeddingDimension: 1024,
},
}),
),
saveRagStoreModelConfig: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
rebuildRagStoreIndex: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
}));
vi.mock('@/api/ragStores', () => ({ vi.mock('@/api/ragStores', () => ({
getRagStoreOverview: vi.fn(() => getRagStoreOverview: vi.fn(() =>
Promise.resolve({ Promise.resolve({
@@ -142,6 +194,8 @@ describe('RagStoresPage', () => {
expect(queryRagStores).toHaveBeenCalled(); expect(queryRagStores).toHaveBeenCalled();
expect(getRagStoreById).toHaveBeenCalledWith('1'); expect(getRagStoreById).toHaveBeenCalledWith('1');
expect(getRagStoreDocumentOverview).toHaveBeenCalledWith('1'); expect(getRagStoreDocumentOverview).toHaveBeenCalledWith('1');
expect(queryModelConfigs).toHaveBeenCalled();
expect(getRagStoreModelConfig).toHaveBeenCalledWith('1');
}); });
it('filters stores by name and updates detail when a store is selected', async () => { it('filters stores by name and updates detail when a store is selected', async () => {
@@ -225,4 +279,23 @@ describe('RagStoresPage', () => {
expect(wrapper.text()).toContain('拖拽文件到此处'); expect(wrapper.text()).toContain('拖拽文件到此处');
expect(wrapper.text()).toContain('产品制度库'); expect(wrapper.text()).toContain('产品制度库');
}); });
it('saves store model config and triggers rebuild index', async () => {
const wrapper = mount(RagStoresPage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
const saveButtons = wrapper.findAll('button').filter((button) => button.text().includes('保存配置'));
await saveButtons[0]?.trigger('click');
await flushPromises();
expect(saveRagStoreModelConfig).toHaveBeenCalled();
const rebuildButtons = wrapper.findAll('button').filter((button) => button.text().includes('重建索引'));
await rebuildButtons[0]?.trigger('click');
await flushPromises();
expect(rebuildRagStoreIndex).toHaveBeenCalledWith('1');
});
}); });

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { RefreshRight, Search } from '@element-plus/icons-vue';
import { onMounted, reactive, ref } from 'vue';
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
import {
queryModelCallLogs,
queryModelConfigs,
queryModelProviders,
type ModelCallLog,
type ModelConfig,
type ModelProvider,
} from '@/api/modelProvider';
const loading = ref(false);
const logs = ref<ModelCallLog[]>([]);
const providers = ref<ModelProvider[]>([]);
const models = ref<ModelConfig[]>([]);
const taskTypeOptions = ref<EnumOption[]>([]);
const statusOptions = ref<EnumOption[]>([]);
const enumError = ref('');
const queryForm = reactive({
taskType: '',
providerId: '',
modelId: '',
status: '',
bizType: '',
});
async function loadBaseData(forceRefresh = false) {
enumError.value = '';
try {
const [enumResult, providerResult, modelResult] = await Promise.all([
loadModelProviderEnumOptions(forceRefresh),
queryModelProviders(),
queryModelConfigs(),
]);
taskTypeOptions.value = enumResult.task_type ?? [];
statusOptions.value = enumResult.call_status ?? [];
providers.value = providerResult.data ?? [];
models.value = modelResult.data ?? [];
} catch (error) {
enumError.value = error instanceof Error ? error.message : '基础数据加载失败';
}
}
async function loadLogs() {
loading.value = true;
try {
const response = await queryModelCallLogs({
taskType: queryForm.taskType || undefined,
providerId: queryForm.providerId || undefined,
modelId: queryForm.modelId || undefined,
status: queryForm.status || undefined,
bizType: queryForm.bizType || undefined,
});
logs.value = response.data ?? [];
} finally {
loading.value = false;
}
}
function resetQuery() {
queryForm.taskType = '';
queryForm.providerId = '';
queryForm.modelId = '';
queryForm.status = '';
queryForm.bizType = '';
loadLogs();
}
function providerName(providerId?: string) {
return providers.value.find((item) => item.id === providerId)?.providerName ?? providerId ?? '-';
}
function modelName(modelId?: string) {
const model = models.value.find((item) => item.id === modelId);
return model?.modelName ?? model?.modelCode ?? modelId ?? '-';
}
onMounted(async () => {
await loadBaseData();
await loadLogs();
});
</script>
<template>
<section class="page-panel">
<div class="page-panel__header">
<h2>调用日志</h2>
<span>Call Logs</span>
</div>
<div class="toolbar">
<el-alert v-if="enumError" type="error" :closable="false" :title="`基础数据加载失败:${enumError}`" show-icon />
<el-form :model="queryForm" inline>
<el-form-item label="任务类型">
<el-select v-model="queryForm.taskType" clearable placeholder="全部">
<el-option v-for="item in taskTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="服务商">
<el-select v-model="queryForm.providerId" clearable placeholder="全部">
<el-option
v-for="provider in providers"
:key="provider.id"
:label="provider.providerName"
:value="provider.id"
/>
</el-select>
</el-form-item>
<el-form-item label="模型">
<el-select v-model="queryForm.modelId" clearable placeholder="全部">
<el-option v-for="model in models" :key="model.id" :label="model.modelName" :value="model.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable placeholder="全部">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="业务类型">
<el-input v-model="queryForm.bizType" clearable placeholder="如 RAG" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadLogs">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
<el-button :icon="RefreshRight" @click="loadBaseData(true)">重试基础数据</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="logs" row-key="id">
<el-table-column prop="requestId" label="请求ID" min-width="150" />
<el-table-column label="服务商" min-width="120">
<template #default="{ row }">{{ providerName(row.providerId) }}</template>
</el-table-column>
<el-table-column label="模型" min-width="120">
<template #default="{ row }">{{ modelName(row.modelId) }}</template>
</el-table-column>
<el-table-column prop="taskType" label="任务类型" min-width="120" />
<el-table-column prop="bizType" label="业务类型" min-width="100" />
<el-table-column prop="bizId" label="业务ID" min-width="120" />
<el-table-column prop="callType" label="调用类型" min-width="100" />
<el-table-column prop="status" label="状态" min-width="90" />
<el-table-column prop="durationMs" label="耗时(ms)" width="100" />
<el-table-column prop="errorCode" label="错误码" min-width="100" />
<el-table-column prop="errorMessage" label="错误摘要" min-width="180" show-overflow-tooltip />
</el-table>
</section>
</template>
<style scoped>
.toolbar {
padding: 16px 22px;
display: grid;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { Delete, Edit, Plus, RefreshRight } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
import {
deleteModelConfig,
queryModelConfigs,
queryModelProviders,
saveModelConfig,
type ModelConfig,
type ModelProvider,
} from '@/api/modelProvider';
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const models = ref<ModelConfig[]>([]);
const providers = ref<ModelProvider[]>([]);
const modelTypeOptions = ref<EnumOption[]>([]);
const enumError = ref('');
const editForm = reactive<ModelConfig>({
providerId: '',
modelCode: '',
modelName: '',
upstreamModel: '',
modelType: '',
embeddingDimension: 1024,
localModel: false,
defaultModel: false,
optionsJson: '{}',
enabled: true,
remark: '',
});
const dialogTitle = computed(() => (editForm.id ? '编辑模型' : '新增模型'));
function resetForm(row?: ModelConfig) {
editForm.id = row?.id;
editForm.providerId = row?.providerId ?? providers.value[0]?.id ?? '';
editForm.modelCode = row?.modelCode ?? '';
editForm.modelName = row?.modelName ?? '';
editForm.upstreamModel = row?.upstreamModel ?? '';
editForm.modelType = row?.modelType ?? modelTypeOptions.value[0]?.value ?? '';
editForm.embeddingDimension = row?.embeddingDimension ?? 1024;
editForm.localModel = row?.localModel ?? false;
editForm.defaultModel = row?.defaultModel ?? false;
editForm.optionsJson = row?.optionsJson ?? '{}';
editForm.enabled = row?.enabled ?? true;
editForm.remark = row?.remark ?? '';
}
async function loadBaseData(forceRefresh = false) {
enumError.value = '';
try {
const [enumResult, providerResult] = await Promise.all([
loadModelProviderEnumOptions(forceRefresh),
queryModelProviders(),
]);
modelTypeOptions.value = enumResult.model_type ?? [];
providers.value = providerResult.data ?? [];
} catch (error) {
enumError.value = error instanceof Error ? error.message : '基础数据加载失败';
}
}
async function loadModels() {
loading.value = true;
try {
const response = await queryModelConfigs();
models.value = response.data ?? [];
} finally {
loading.value = false;
}
}
function openCreateDialog() {
resetForm();
dialogVisible.value = true;
}
function openEditDialog(row: ModelConfig) {
resetForm(row);
dialogVisible.value = true;
}
async function submitModel() {
if (!editForm.providerId || !editForm.modelCode || !editForm.modelName || !editForm.modelType) {
ElMessage.warning('请填写服务商、模型编码、模型名称和模型类型');
return;
}
saving.value = true;
try {
await saveModelConfig({ ...editForm });
ElMessage.success('保存成功');
dialogVisible.value = false;
await loadModels();
} finally {
saving.value = false;
}
}
async function removeModel(row: ModelConfig) {
if (!row.id) {
return;
}
await ElMessageBox.confirm(`确认删除模型「${row.modelName}」?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
await deleteModelConfig(row.id);
ElMessage.success('已删除');
await loadModels();
}
function providerName(providerId?: string) {
const provider = providers.value.find((item) => item.id === providerId);
return provider?.providerName ?? providerId ?? '-';
}
onMounted(async () => {
await loadBaseData();
resetForm();
await loadModels();
});
</script>
<template>
<section class="page-panel">
<div class="page-panel__header">
<h2>模型配置</h2>
<span>Models</span>
</div>
<div class="toolbar">
<el-alert v-if="enumError" type="error" :closable="false" :title="`基础数据加载失败:${enumError}`" show-icon />
<div class="toolbar__actions">
<el-button @click="loadBaseData(true)">重试基础数据</el-button>
<el-button :icon="RefreshRight" @click="loadModels">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增模型</el-button>
</div>
</div>
<el-table v-loading="loading" :data="models" row-key="id">
<el-table-column prop="modelCode" label="模型编码" min-width="140" />
<el-table-column prop="modelName" label="模型名称" min-width="140" />
<el-table-column label="服务商" min-width="130">
<template #default="{ row }">{{ providerName(row.providerId) }}</template>
</el-table-column>
<el-table-column prop="upstreamModel" label="上游模型" min-width="140" />
<el-table-column prop="modelType" label="模型类型" min-width="110" />
<el-table-column prop="embeddingDimension" label="向量维度" width="100" />
<el-table-column label="本地模型" width="90">
<template #default="{ row }">
<el-tag :type="row.localModel ? 'success' : 'info'">{{ row.localModel ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="默认模型" width="90">
<template #default="{ row }">
<el-tag :type="row.defaultModel ? 'success' : 'info'">{{ row.defaultModel ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="removeModel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="680px">
<el-form :model="editForm" label-width="108px">
<el-form-item label="服务商" required>
<el-select v-model="editForm.providerId" placeholder="请选择服务商">
<el-option
v-for="provider in providers"
:key="provider.id"
:label="provider.providerName"
:value="provider.id"
/>
</el-select>
</el-form-item>
<el-form-item label="模型编码" required>
<el-input v-model="editForm.modelCode" placeholder="如 TEXT_EMBED_3L" />
</el-form-item>
<el-form-item label="模型名称" required>
<el-input v-model="editForm.modelName" />
</el-form-item>
<el-form-item label="上游模型">
<el-input v-model="editForm.upstreamModel" />
</el-form-item>
<el-form-item label="模型类型" required>
<el-select v-model="editForm.modelType" placeholder="请选择">
<el-option v-for="item in modelTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="向量维度">
<el-input-number v-model="editForm.embeddingDimension" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="选项JSON">
<el-input v-model="editForm.optionsJson" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="本地模型">
<el-switch v-model="editForm.localModel" />
</el-form-item>
<el-form-item label="默认模型">
<el-switch v-model="editForm.defaultModel" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="editForm.enabled" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitModel">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 22px;
}
.toolbar__actions {
display: flex;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,278 @@
<script setup lang="ts">
import { Delete, Edit, Plus, RefreshRight } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
import {
checkModelProviderHealth,
deleteModelProvider,
queryModelProviders,
saveModelProvider,
type ModelProvider,
} from '@/api/modelProvider';
const loading = ref(false);
const saving = ref(false);
const checkingId = ref<string | null>(null);
const dialogVisible = ref(false);
const providers = ref<ModelProvider[]>([]);
const enumLoading = ref(false);
const enumError = ref('');
const providerTypeOptions = ref<EnumOption[]>([]);
const protocolTypeOptions = ref<EnumOption[]>([]);
const authTypeOptions = ref<EnumOption[]>([]);
const healthStatusOptions = ref<EnumOption[]>([]);
const editForm = reactive<ModelProvider>({
providerCode: '',
providerName: '',
providerType: '',
protocolType: '',
baseUrl: '',
authType: '',
secretRef: '',
timeoutMs: 60000,
priority: 100,
enabled: true,
remark: '',
});
const dialogTitle = computed(() => (editForm.id ? '编辑服务商' : '新增服务商'));
function resetForm(row?: ModelProvider) {
editForm.id = row?.id;
editForm.providerCode = row?.providerCode ?? '';
editForm.providerName = row?.providerName ?? '';
editForm.providerType = row?.providerType ?? providerTypeOptions.value[0]?.value ?? '';
editForm.protocolType = row?.protocolType ?? protocolTypeOptions.value[0]?.value ?? '';
editForm.baseUrl = row?.baseUrl ?? '';
editForm.authType = row?.authType ?? authTypeOptions.value[0]?.value ?? '';
editForm.secretRef = row?.secretRef ?? '';
editForm.timeoutMs = row?.timeoutMs ?? 60000;
editForm.priority = row?.priority ?? 100;
editForm.enabled = row?.enabled ?? true;
editForm.remark = row?.remark ?? '';
}
async function loadEnums(forceRefresh = false) {
enumLoading.value = true;
enumError.value = '';
try {
const result = await loadModelProviderEnumOptions(forceRefresh);
providerTypeOptions.value = result.provider_type ?? [];
protocolTypeOptions.value = result.protocol_type ?? [];
authTypeOptions.value = result.auth_type ?? [];
healthStatusOptions.value = result.health_status ?? [];
} catch (error) {
enumError.value = error instanceof Error ? error.message : '枚举加载失败';
} finally {
enumLoading.value = false;
}
}
async function loadProviders() {
loading.value = true;
try {
const response = await queryModelProviders();
providers.value = response.data ?? [];
} finally {
loading.value = false;
}
}
function openCreateDialog() {
resetForm();
dialogVisible.value = true;
}
function openEditDialog(row: ModelProvider) {
resetForm(row);
dialogVisible.value = true;
}
async function submitProvider() {
if (!editForm.providerCode || !editForm.providerName || !editForm.baseUrl) {
ElMessage.warning('请填写服务商编码、服务商名称和基础地址');
return;
}
saving.value = true;
try {
await saveModelProvider({ ...editForm });
ElMessage.success('保存成功');
dialogVisible.value = false;
await loadProviders();
} finally {
saving.value = false;
}
}
async function removeProvider(row: ModelProvider) {
if (!row.id) {
return;
}
await ElMessageBox.confirm(`确认删除服务商「${row.providerName}」?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
await deleteModelProvider(row.id);
ElMessage.success('已删除');
await loadProviders();
}
async function checkHealth(row: ModelProvider) {
if (!row.id) {
return;
}
checkingId.value = row.id;
try {
await checkModelProviderHealth(row.id);
ElMessage.success('健康检查已完成');
await loadProviders();
} finally {
checkingId.value = null;
}
}
function healthLabel(value?: string) {
const option = healthStatusOptions.value.find((item) => item.value === value);
return option?.label ?? value ?? '-';
}
onMounted(async () => {
await loadEnums();
resetForm();
await loadProviders();
});
</script>
<template>
<section class="page-panel">
<div class="page-panel__header">
<h2>模型服务商</h2>
<span>Providers</span>
</div>
<div class="toolbar">
<div class="toolbar__left">
<el-alert
v-if="enumError"
type="error"
:closable="false"
:title="`枚举加载失败:${enumError}`"
show-icon
/>
</div>
<div class="toolbar__actions">
<el-button :loading="enumLoading" @click="loadEnums(true)">重试枚举</el-button>
<el-button :icon="RefreshRight" @click="loadProviders">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增服务商</el-button>
</div>
</div>
<el-table v-loading="loading" :data="providers" row-key="id">
<el-table-column prop="providerCode" label="服务商编码" min-width="140" />
<el-table-column prop="providerName" label="服务商名称" min-width="140" />
<el-table-column prop="providerType" label="服务商类型" min-width="120" />
<el-table-column prop="protocolType" label="协议类型" min-width="130" />
<el-table-column prop="authType" label="鉴权类型" min-width="120" />
<el-table-column prop="secretRef" label="密钥引用" min-width="140" />
<el-table-column label="已配置密钥" width="110">
<template #default="{ row }">
<el-tag :type="row.hasApiKey ? 'success' : 'info'">{{ row.hasApiKey ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="健康状态" width="110">
<template #default="{ row }">
<el-tag>{{ healthLabel(row.healthStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button
link
type="primary"
:loading="checkingId === row.id"
@click="checkHealth(row)"
>
健康检查
</el-button>
<el-button link type="danger" :icon="Delete" @click="removeProvider(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px">
<el-form :model="editForm" label-width="108px">
<el-form-item label="服务商编码" required>
<el-input v-model="editForm.providerCode" placeholder="如 OPENAI_MAIN" />
</el-form-item>
<el-form-item label="服务商名称" required>
<el-input v-model="editForm.providerName" placeholder="如 OpenAI 主账号" />
</el-form-item>
<el-form-item label="服务商类型">
<el-select v-model="editForm.providerType" placeholder="请选择">
<el-option v-for="item in providerTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="协议类型">
<el-select v-model="editForm.protocolType" placeholder="请选择">
<el-option v-for="item in protocolTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="基础地址" required>
<el-input v-model="editForm.baseUrl" placeholder="https://api.example.com" />
</el-form-item>
<el-form-item label="鉴权类型">
<el-select v-model="editForm.authType" placeholder="请选择">
<el-option v-for="item in authTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="密钥引用">
<el-input v-model="editForm.secretRef" placeholder="如 OPENAI_API_KEY" />
</el-form-item>
<el-form-item label="超时(毫秒)">
<el-input-number v-model="editForm.timeoutMs" :min="1000" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="editForm.priority" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="editForm.enabled" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitProvider">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 22px;
}
.toolbar__left {
flex: 1;
}
.toolbar__actions {
display: flex;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { Delete, Edit, Plus, RefreshRight } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
import {
deleteModelRouteRule,
queryModelConfigs,
queryModelRouteRules,
saveModelRouteRule,
type ModelConfig,
type ModelRouteRule,
} from '@/api/modelProvider';
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const routes = ref<ModelRouteRule[]>([]);
const models = ref<ModelConfig[]>([]);
const taskTypeOptions = ref<EnumOption[]>([]);
const routeStrategyOptions = ref<EnumOption[]>([]);
const matchScopeOptions = ref<EnumOption[]>([]);
const enumError = ref('');
const editForm = reactive<ModelRouteRule>({
routeCode: '',
routeName: '',
taskType: '',
matchScope: '',
scopeId: '',
primaryModelId: '',
fallbackModelIdsJson: '[]',
routeStrategy: '',
maxLatencyMs: 0,
enabled: true,
remark: '',
});
const dialogTitle = computed(() => (editForm.id ? '编辑路由规则' : '新增路由规则'));
function resetForm(row?: ModelRouteRule) {
editForm.id = row?.id;
editForm.routeCode = row?.routeCode ?? '';
editForm.routeName = row?.routeName ?? '';
editForm.taskType = row?.taskType ?? taskTypeOptions.value[0]?.value ?? '';
editForm.matchScope = row?.matchScope ?? matchScopeOptions.value[0]?.value ?? '';
editForm.scopeId = row?.scopeId ?? '';
editForm.primaryModelId = row?.primaryModelId ?? models.value[0]?.id ?? '';
editForm.fallbackModelIdsJson = row?.fallbackModelIdsJson ?? '[]';
editForm.routeStrategy = row?.routeStrategy ?? routeStrategyOptions.value[0]?.value ?? '';
editForm.maxLatencyMs = row?.maxLatencyMs ?? 0;
editForm.enabled = row?.enabled ?? true;
editForm.remark = row?.remark ?? '';
}
async function loadBaseData(forceRefresh = false) {
enumError.value = '';
try {
const [enumResult, modelResult] = await Promise.all([
loadModelProviderEnumOptions(forceRefresh),
queryModelConfigs(),
]);
taskTypeOptions.value = enumResult.task_type ?? [];
routeStrategyOptions.value = enumResult.route_strategy ?? [];
matchScopeOptions.value = enumResult.match_scope ?? [];
models.value = modelResult.data ?? [];
} catch (error) {
enumError.value = error instanceof Error ? error.message : '基础数据加载失败';
}
}
async function loadRouteRules() {
loading.value = true;
try {
const response = await queryModelRouteRules();
routes.value = response.data ?? [];
} finally {
loading.value = false;
}
}
function openCreateDialog() {
resetForm();
dialogVisible.value = true;
}
function openEditDialog(row: ModelRouteRule) {
resetForm(row);
dialogVisible.value = true;
}
async function submitRouteRule() {
if (!editForm.routeCode || !editForm.taskType || !editForm.primaryModelId) {
ElMessage.warning('请填写路由编码、任务类型和主模型');
return;
}
try {
JSON.parse(editForm.fallbackModelIdsJson ?? '[]');
} catch {
ElMessage.warning('降级模型JSON格式不正确');
return;
}
saving.value = true;
try {
await saveModelRouteRule({ ...editForm });
ElMessage.success('保存成功');
dialogVisible.value = false;
await loadRouteRules();
} finally {
saving.value = false;
}
}
async function removeRouteRule(row: ModelRouteRule) {
if (!row.id) {
return;
}
await ElMessageBox.confirm(`确认删除路由规则「${row.routeName || row.routeCode}」?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
await deleteModelRouteRule(row.id);
ElMessage.success('已删除');
await loadRouteRules();
}
function modelLabel(modelId?: string) {
const model = models.value.find((item) => item.id === modelId);
return model?.modelName ?? model?.modelCode ?? modelId ?? '-';
}
onMounted(async () => {
await loadBaseData();
resetForm();
await loadRouteRules();
});
</script>
<template>
<section class="page-panel">
<div class="page-panel__header">
<h2>路由规则</h2>
<span>Routes</span>
</div>
<div class="toolbar">
<el-alert v-if="enumError" type="error" :closable="false" :title="`基础数据加载失败:${enumError}`" show-icon />
<div class="toolbar__actions">
<el-button @click="loadBaseData(true)">重试基础数据</el-button>
<el-button :icon="RefreshRight" @click="loadRouteRules">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增规则</el-button>
</div>
</div>
<el-table v-loading="loading" :data="routes" row-key="id">
<el-table-column prop="routeCode" label="规则编码" min-width="140" />
<el-table-column prop="routeName" label="规则名称" min-width="140" />
<el-table-column prop="taskType" label="任务类型" min-width="120" />
<el-table-column prop="matchScope" label="匹配范围" min-width="100" />
<el-table-column label="主模型" min-width="140">
<template #default="{ row }">{{ modelLabel(row.primaryModelId) }}</template>
</el-table-column>
<el-table-column prop="fallbackModelIdsJson" label="降级模型JSON" min-width="180" show-overflow-tooltip />
<el-table-column prop="routeStrategy" label="策略" min-width="100" />
<el-table-column prop="maxLatencyMs" label="最大延迟(ms)" width="120" />
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="removeRouteRule(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="680px">
<el-form :model="editForm" label-width="120px">
<el-form-item label="规则编码" required>
<el-input v-model="editForm.routeCode" placeholder="如 RAG_EMBEDDING_GLOBAL" />
</el-form-item>
<el-form-item label="规则名称">
<el-input v-model="editForm.routeName" />
</el-form-item>
<el-form-item label="任务类型" required>
<el-select v-model="editForm.taskType">
<el-option v-for="item in taskTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="匹配范围">
<el-select v-model="editForm.matchScope">
<el-option v-for="item in matchScopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="范围业务ID">
<el-input v-model="editForm.scopeId" />
</el-form-item>
<el-form-item label="主模型" required>
<el-select v-model="editForm.primaryModelId">
<el-option v-for="item in models" :key="item.id" :label="`${item.modelName}(${item.modelCode})`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="降级模型JSON">
<el-input v-model="editForm.fallbackModelIdsJson" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="路由策略">
<el-select v-model="editForm.routeStrategy">
<el-option v-for="item in routeStrategyOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="最大延迟(ms)">
<el-input-number v-model="editForm.maxLatencyMs" :min="0" controls-position="right" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="editForm.enabled" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitRouteRule">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 22px;
}
.toolbar__actions {
display: flex;
gap: 8px;
}
</style>

View File

@@ -12,6 +12,10 @@ describe('router', () => {
expect(paths).toContain('/rag/documents'); expect(paths).toContain('/rag/documents');
expect(paths).toContain('/rag/tasks/chunk'); expect(paths).toContain('/rag/tasks/chunk');
expect(paths).toContain('/system/enums'); expect(paths).toContain('/system/enums');
expect(paths).toContain('/system/model/providers');
expect(paths).toContain('/system/model/configs');
expect(paths).toContain('/system/model/routes');
expect(paths).toContain('/system/model/call-logs');
expect(paths).toContain('/:pathMatch(.*)*'); expect(paths).toContain('/:pathMatch(.*)*');
}); });
}); });

View File

@@ -7,6 +7,10 @@ import RagStoresPage from '@/pages/rag/RagStoresPage.vue';
import RagChunkTasksPage from '@/pages/rag/tasks/RagChunkTasksPage.vue'; import RagChunkTasksPage from '@/pages/rag/tasks/RagChunkTasksPage.vue';
import RagWorkbenchPage from '@/pages/rag/workbench/RagWorkbenchPage.vue'; import RagWorkbenchPage from '@/pages/rag/workbench/RagWorkbenchPage.vue';
import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue'; import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue';
import ModelProvidersPage from '@/pages/system/ModelProvidersPage.vue';
import ModelConfigsPage from '@/pages/system/ModelConfigsPage.vue';
import ModelRouteRulesPage from '@/pages/system/ModelRouteRulesPage.vue';
import ModelCallLogsPage from '@/pages/system/ModelCallLogsPage.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
export const routes: RouteRecordRaw[] = [ export const routes: RouteRecordRaw[] = [
@@ -20,6 +24,30 @@ export const routes: RouteRecordRaw[] = [
component: SystemEnumsPage, component: SystemEnumsPage,
meta: { title: '系统枚举' }, meta: { title: '系统枚举' },
}, },
{
path: '/system/model/providers',
name: 'system-model-providers',
component: ModelProvidersPage,
meta: { title: '模型服务商' },
},
{
path: '/system/model/configs',
name: 'system-model-configs',
component: ModelConfigsPage,
meta: { title: '模型配置' },
},
{
path: '/system/model/routes',
name: 'system-model-routes',
component: ModelRouteRulesPage,
meta: { title: '路由规则' },
},
{
path: '/system/model/call-logs',
name: 'system-model-call-logs',
component: ModelCallLogsPage,
meta: { title: '调用日志' },
},
{ {
path: '/rag/stores', path: '/rag/stores',
name: 'rag-stores', name: 'rag-stores',
@@ -67,6 +95,30 @@ const routerRoutes: RouteRecordRaw[] = [
component: SystemEnumsPage, component: SystemEnumsPage,
meta: { title: '系统枚举' }, meta: { title: '系统枚举' },
}, },
{
path: 'system/model/providers',
name: 'system-model-providers',
component: ModelProvidersPage,
meta: { title: '模型服务商' },
},
{
path: 'system/model/configs',
name: 'system-model-configs',
component: ModelConfigsPage,
meta: { title: '模型配置' },
},
{
path: 'system/model/routes',
name: 'system-model-routes',
component: ModelRouteRulesPage,
meta: { title: '路由规则' },
},
{
path: 'system/model/call-logs',
name: 'system-model-call-logs',
component: ModelCallLogsPage,
meta: { title: '调用日志' },
},
{ {
path: 'rag/stores', path: 'rag/stores',
name: 'rag-stores', name: 'rag-stores',