- 新增 OpenAI 兼容统一模型调用链路、模型发布配置与批量发布能力 - 重构模型管理页面入口与统一网关工作区,更新服务商 logo 资源与模型 ID 文案 - 收口全新库初始化脚本,仅保留服务商种子并整理统一网关 migration
1261 lines
30 KiB
Vue
1261 lines
30 KiB
Vue
<script setup lang="ts">
|
||
import type {
|
||
BatchModelInvokePublishPayload,
|
||
llmType,
|
||
ModelInvokeConfigPayload,
|
||
} from '#/api/ai/llm';
|
||
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
|
||
import { useAppConfig } from '@easyflow/hooks';
|
||
|
||
import {
|
||
ElAlert,
|
||
ElButton,
|
||
ElCheckbox,
|
||
ElEmpty,
|
||
ElForm,
|
||
ElFormItem,
|
||
ElInput,
|
||
ElMessage,
|
||
ElMessageBox,
|
||
ElSwitch,
|
||
ElTag,
|
||
} from 'element-plus';
|
||
|
||
import {
|
||
batchUpdateInvokePublishStatus,
|
||
getInvokeModelList,
|
||
updateModelInvokeConfig,
|
||
} from '#/api/ai/llm';
|
||
import { api } from '#/api/request';
|
||
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'updated'): void;
|
||
}>();
|
||
|
||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||
|
||
interface DraftState {
|
||
id: string;
|
||
invokeCode: string;
|
||
publishEnabled: boolean;
|
||
}
|
||
|
||
interface FilterState {
|
||
keyword: string;
|
||
}
|
||
|
||
const isLoading = ref(false);
|
||
const isSaving = ref(false);
|
||
const isPublishing = ref(false);
|
||
const isBatchPublishing = ref(false);
|
||
const lastErrorMessage = ref('');
|
||
const selectedModelId = ref('');
|
||
const autoGeneratedInvokeCode = ref('');
|
||
const publishBaseUrl = ref('');
|
||
const checkedModelIds = ref<string[]>([]);
|
||
const modelRows = ref<llmType[]>([]);
|
||
|
||
const filterState = reactive<FilterState>({
|
||
keyword: '',
|
||
});
|
||
|
||
const draft = reactive<DraftState>({
|
||
id: '',
|
||
invokeCode: '',
|
||
publishEnabled: false,
|
||
});
|
||
|
||
const normalizePublishBaseUrl = (value?: string) => {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) {
|
||
return '';
|
||
}
|
||
if (/^https?:\/\//i.test(raw)) {
|
||
return raw.replace(/\/+$/, '');
|
||
}
|
||
if (raw.startsWith('//')) {
|
||
return `${window.location.protocol}${raw}`.replace(/\/+$/, '');
|
||
}
|
||
const firstSegment = raw.split('/')[0] || '';
|
||
const hasPort = /:\d+$/.test(firstSegment);
|
||
let hostAndPath = raw;
|
||
if (
|
||
!hasPort &&
|
||
firstSegment === window.location.hostname &&
|
||
window.location.port
|
||
) {
|
||
hostAndPath = `${firstSegment}:${window.location.port}${raw.slice(firstSegment.length)}`;
|
||
}
|
||
return `${window.location.protocol}//${hostAndPath}`.replace(/\/+$/, '');
|
||
};
|
||
|
||
const resolveFlowBaseUrl = (value?: string) => {
|
||
const normalized = normalizePublishBaseUrl(value);
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
|
||
if (/\/flow$/i.test(normalized)) {
|
||
return normalized;
|
||
}
|
||
|
||
return `${normalized}/flow`;
|
||
};
|
||
|
||
const apiEndpoint = computed(() => {
|
||
const base =
|
||
resolveFlowBaseUrl(publishBaseUrl.value) ||
|
||
resolveFlowBaseUrl(apiURL) ||
|
||
resolveFlowBaseUrl(window.location.origin);
|
||
return `${base}/v1/chat/completions`;
|
||
});
|
||
|
||
const selectedModel = computed(() =>
|
||
modelRows.value.find((item) => String(item.id) === selectedModelId.value),
|
||
);
|
||
|
||
const visibleModelIds = computed(() =>
|
||
filteredModels.value.map((item) => String(item.id)),
|
||
);
|
||
|
||
const checkedCount = computed(() => checkedModelIds.value.length);
|
||
const checkedModels = computed(() =>
|
||
modelRows.value.filter((item) =>
|
||
checkedModelIds.value.includes(String(item.id)),
|
||
),
|
||
);
|
||
|
||
const shouldBatchUnpublish = computed(
|
||
() =>
|
||
checkedModels.value.length > 0 &&
|
||
checkedModels.value.every((item) => Boolean(item.publishEnabled)),
|
||
);
|
||
|
||
const batchActionLabel = computed(() =>
|
||
shouldBatchUnpublish.value ? '取消发布' : '发布',
|
||
);
|
||
|
||
const isAllVisibleChecked = computed(
|
||
() =>
|
||
visibleModelIds.value.length > 0 &&
|
||
visibleModelIds.value.every((id) => checkedModelIds.value.includes(id)),
|
||
);
|
||
|
||
const isPartiallyVisibleChecked = computed(
|
||
() =>
|
||
checkedModelIds.value.length > 0 &&
|
||
visibleModelIds.value.some((id) => checkedModelIds.value.includes(id)) &&
|
||
!isAllVisibleChecked.value,
|
||
);
|
||
|
||
const filteredModels = computed(() => {
|
||
const keyword = filterState.keyword.trim().toLowerCase();
|
||
|
||
return modelRows.value.filter((item) => {
|
||
if (!keyword) {
|
||
return true;
|
||
}
|
||
|
||
const searchTargets = [
|
||
item.title,
|
||
item.modelName,
|
||
item.invokeCode,
|
||
item.modelProvider?.providerName,
|
||
]
|
||
.filter(Boolean)
|
||
.map((text) => String(text).toLowerCase());
|
||
|
||
return searchTargets.some((text) => text.includes(keyword));
|
||
});
|
||
});
|
||
|
||
const providerType = computed(
|
||
() =>
|
||
selectedModel.value?.modelProvider?.providerType ||
|
||
selectedModel.value?.aiLlmProvider?.providerType ||
|
||
'-',
|
||
);
|
||
|
||
const upstreamModelName = computed(
|
||
() => selectedModel.value?.modelName || selectedModel.value?.llmModel || '-',
|
||
);
|
||
|
||
const capabilityTags = computed(() => {
|
||
const tags = ['文本', '流式'];
|
||
if (selectedModel.value?.supportImage) {
|
||
tags.push(
|
||
selectedModel.value?.supportImageB64Only
|
||
? '图片输入(Base64)'
|
||
: '图片输入',
|
||
);
|
||
}
|
||
if (selectedModel.value?.supportTool) {
|
||
tags.push('tools');
|
||
}
|
||
if (selectedModel.value?.supportToolMessage) {
|
||
tags.push('tool 消息');
|
||
}
|
||
return tags;
|
||
});
|
||
|
||
const authExample = computed(() => 'Authorization: Bearer <your-access-token>');
|
||
|
||
const statusHints = computed(() => {
|
||
const hints: string[] = [];
|
||
if (!draft.invokeCode.trim()) {
|
||
hints.push(
|
||
'当前未单独配置对外模型标识(invokeCode),保存时会默认按上游模型 ID 生成。',
|
||
);
|
||
}
|
||
return hints;
|
||
});
|
||
|
||
const curlExample = computed(
|
||
() => `curl ${apiEndpoint.value} \\
|
||
-H "Content-Type: application/json" \\
|
||
-H "${authExample.value}" \\
|
||
-d '{
|
||
"model": "${draft.invokeCode.trim() || 'your-model-code'}",
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": "你好,介绍一下你自己。"
|
||
}
|
||
]
|
||
}'`,
|
||
);
|
||
|
||
const jsExample = computed(
|
||
() => `await fetch("${apiEndpoint.value}", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer <your-access-token>"
|
||
},
|
||
body: JSON.stringify({
|
||
model: "${draft.invokeCode.trim() || 'your-model-code'}",
|
||
messages: [
|
||
{ role: "user", content: "你好,介绍一下你自己。" }
|
||
]
|
||
})
|
||
});`,
|
||
);
|
||
|
||
const pythonExample = computed(
|
||
() => `import requests
|
||
|
||
resp = requests.post(
|
||
"${apiEndpoint.value}",
|
||
headers={
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer <your-access-token>",
|
||
},
|
||
json={
|
||
"model": "${draft.invokeCode.trim() || 'your-model-code'}",
|
||
"messages": [
|
||
{"role": "user", "content": "你好,介绍一下你自己。"}
|
||
],
|
||
},
|
||
timeout=60,
|
||
)
|
||
|
||
print(resp.json())`,
|
||
);
|
||
|
||
const buildDefaultInvokeCode = (modelName?: string) => {
|
||
const normalized = String(modelName || '')
|
||
.trim()
|
||
.replaceAll(/[^\w.:-]+/g, '-')
|
||
.replaceAll(/-+/g, '-')
|
||
.replaceAll(/^[^\da-z]+/gi, '')
|
||
.slice(0, 128);
|
||
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
|
||
return normalized.length === 1 ? `${normalized}-model` : normalized;
|
||
};
|
||
|
||
const isInvokeCodeDirty = computed(() => {
|
||
if (!selectedModel.value) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
draft.invokeCode.trim() !==
|
||
(selectedModel.value.invokeCode || autoGeneratedInvokeCode.value)
|
||
);
|
||
});
|
||
|
||
const syncDraft = (model?: llmType | null) => {
|
||
if (!model) {
|
||
selectedModelId.value = '';
|
||
draft.id = '';
|
||
draft.invokeCode = '';
|
||
draft.publishEnabled = false;
|
||
autoGeneratedInvokeCode.value = '';
|
||
return;
|
||
}
|
||
|
||
autoGeneratedInvokeCode.value = buildDefaultInvokeCode(
|
||
model.modelName || model.llmModel,
|
||
);
|
||
selectedModelId.value = String(model.id);
|
||
draft.id = String(model.id);
|
||
draft.invokeCode = model.invokeCode || autoGeneratedInvokeCode.value;
|
||
draft.publishEnabled = Boolean(model.publishEnabled);
|
||
};
|
||
|
||
const confirmDiscardDraft = async () => {
|
||
if (!isInvokeCodeDirty.value) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'当前统一网关配置还没有保存,离开后未保存修改会丢失。',
|
||
'未保存的更改',
|
||
{
|
||
confirmButtonText: '继续',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
},
|
||
);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const loadModels = async (preferredId?: string) => {
|
||
isLoading.value = true;
|
||
lastErrorMessage.value = '';
|
||
|
||
try {
|
||
const res = await getInvokeModelList();
|
||
|
||
if (res.errorCode !== 0) {
|
||
modelRows.value = [];
|
||
syncDraft();
|
||
lastErrorMessage.value = res.message || '加载统一网关模型失败';
|
||
return;
|
||
}
|
||
|
||
modelRows.value = res.data || [];
|
||
let nextSelectedId = String(modelRows.value[0]?.id || '');
|
||
|
||
if (
|
||
preferredId &&
|
||
modelRows.value.some((item) => String(item.id) === preferredId)
|
||
) {
|
||
nextSelectedId = preferredId;
|
||
} else if (
|
||
selectedModelId.value &&
|
||
modelRows.value.some((item) => String(item.id) === selectedModelId.value)
|
||
) {
|
||
nextSelectedId = selectedModelId.value;
|
||
}
|
||
|
||
const nextModel = modelRows.value.find(
|
||
(item) => String(item.id) === nextSelectedId,
|
||
);
|
||
checkedModelIds.value = checkedModelIds.value.filter((id) =>
|
||
modelRows.value.some((item) => String(item.id) === id),
|
||
);
|
||
syncDraft(nextModel || null);
|
||
} catch (error) {
|
||
modelRows.value = [];
|
||
syncDraft();
|
||
lastErrorMessage.value =
|
||
(error as Error)?.message || '加载统一网关模型失败';
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadPublishBaseUrl = async () => {
|
||
try {
|
||
const res = await api.get('/api/v1/sysOption/list', {
|
||
params: {
|
||
keys: ['chat_publish_base_url'],
|
||
},
|
||
});
|
||
if (res.errorCode === 0) {
|
||
publishBaseUrl.value = String(
|
||
res.data?.chat_publish_base_url || '',
|
||
).trim();
|
||
}
|
||
} catch {
|
||
publishBaseUrl.value = '';
|
||
}
|
||
};
|
||
|
||
const handleSelectModel = async (model: llmType) => {
|
||
if (String(model.id) === selectedModelId.value) {
|
||
return;
|
||
}
|
||
|
||
if (!(await confirmDiscardDraft())) {
|
||
return;
|
||
}
|
||
|
||
syncDraft(model);
|
||
};
|
||
|
||
const copyText = async (value: string) => {
|
||
if (!navigator?.clipboard?.writeText) {
|
||
return;
|
||
}
|
||
await navigator.clipboard.writeText(value);
|
||
ElMessage.success('已复制');
|
||
};
|
||
|
||
const syncCheckedModelIds = () => {
|
||
const validIds = new Set(modelRows.value.map((item) => String(item.id)));
|
||
checkedModelIds.value = checkedModelIds.value.filter((id) =>
|
||
validIds.has(id),
|
||
);
|
||
};
|
||
|
||
const applyUpdatedModel = (
|
||
updatedModel: llmType,
|
||
options?: {
|
||
draftInvokeCode?: string;
|
||
preserveInvokeCodeDraft?: boolean;
|
||
},
|
||
) => {
|
||
syncCheckedModelIds();
|
||
modelRows.value = modelRows.value.map((item) =>
|
||
String(item.id) === String(updatedModel.id) ? updatedModel : item,
|
||
);
|
||
|
||
if (String(updatedModel.id) !== selectedModelId.value) {
|
||
return;
|
||
}
|
||
|
||
autoGeneratedInvokeCode.value = buildDefaultInvokeCode(
|
||
updatedModel.modelName || updatedModel.llmModel,
|
||
);
|
||
draft.id = String(updatedModel.id);
|
||
draft.publishEnabled = Boolean(updatedModel.publishEnabled);
|
||
draft.invokeCode = options?.preserveInvokeCodeDraft
|
||
? options.draftInvokeCode || ''
|
||
: updatedModel.invokeCode || autoGeneratedInvokeCode.value;
|
||
};
|
||
|
||
const applyUpdatedModels = (
|
||
updatedModels: llmType[],
|
||
options?: {
|
||
draftInvokeCode?: string;
|
||
preserveInvokeCodeDraft?: boolean;
|
||
},
|
||
) => {
|
||
if (updatedModels.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const updatedMap = new Map(
|
||
updatedModels.map((item) => [String(item.id), item] as const),
|
||
);
|
||
modelRows.value = modelRows.value.map(
|
||
(item) => updatedMap.get(String(item.id)) || item,
|
||
);
|
||
syncCheckedModelIds();
|
||
|
||
const currentModel = updatedMap.get(selectedModelId.value);
|
||
if (!currentModel) {
|
||
return;
|
||
}
|
||
|
||
autoGeneratedInvokeCode.value = buildDefaultInvokeCode(
|
||
currentModel.modelName || currentModel.llmModel,
|
||
);
|
||
draft.id = String(currentModel.id);
|
||
draft.publishEnabled = Boolean(currentModel.publishEnabled);
|
||
draft.invokeCode = options?.preserveInvokeCodeDraft
|
||
? options.draftInvokeCode || ''
|
||
: currentModel.invokeCode || autoGeneratedInvokeCode.value;
|
||
};
|
||
|
||
const handleToggleModelChecked = (modelId: string, checked: boolean) => {
|
||
const next = new Set(checkedModelIds.value);
|
||
if (checked) {
|
||
next.add(modelId);
|
||
} else {
|
||
next.delete(modelId);
|
||
}
|
||
checkedModelIds.value = [...next];
|
||
};
|
||
|
||
const handleToggleVisibleChecked = (checked: boolean | number | string) => {
|
||
const next = new Set(checkedModelIds.value);
|
||
for (const modelId of visibleModelIds.value) {
|
||
if (checked) {
|
||
next.add(modelId);
|
||
} else {
|
||
next.delete(modelId);
|
||
}
|
||
}
|
||
checkedModelIds.value = [...next];
|
||
};
|
||
|
||
const handlePublishToggle = async (nextValue: boolean) => {
|
||
if (!draft.id || !selectedModel.value || isPublishing.value) {
|
||
return;
|
||
}
|
||
|
||
const previousValue = draft.publishEnabled;
|
||
const draftInvokeCode = draft.invokeCode;
|
||
const preserveInvokeCodeDraft = isInvokeCodeDirty.value;
|
||
|
||
draft.publishEnabled = nextValue;
|
||
isPublishing.value = true;
|
||
|
||
try {
|
||
const payload: ModelInvokeConfigPayload = {
|
||
id: draft.id,
|
||
invokeCode: draft.invokeCode.trim(),
|
||
publishEnabled: nextValue,
|
||
};
|
||
const res = await updateModelInvokeConfig(payload);
|
||
|
||
if (res.errorCode !== 0) {
|
||
draft.publishEnabled = previousValue;
|
||
ElMessage.error(res.message || '更新发布状态失败');
|
||
return;
|
||
}
|
||
|
||
applyUpdatedModel(res.data as llmType, {
|
||
preserveInvokeCodeDraft,
|
||
draftInvokeCode,
|
||
});
|
||
ElMessage.success(nextValue ? '已发布该模型' : '已关闭该模型发布');
|
||
emit('updated');
|
||
} finally {
|
||
isPublishing.value = false;
|
||
}
|
||
};
|
||
|
||
const handleBatchPublish = async () => {
|
||
const targetIds = checkedModelIds.value;
|
||
|
||
if (targetIds.length === 0) {
|
||
ElMessage.warning('请先选择模型');
|
||
return;
|
||
}
|
||
|
||
const publishEnabled = !shouldBatchUnpublish.value;
|
||
const actionText = publishEnabled ? '发布' : '取消发布';
|
||
const scopeText = `所选 ${targetIds.length} 个模型`;
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确认${actionText}${scopeText}吗?`,
|
||
`批量${actionText}`,
|
||
{
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
},
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
const draftInvokeCode = draft.invokeCode;
|
||
const preserveInvokeCodeDraft = isInvokeCodeDirty.value;
|
||
isBatchPublishing.value = true;
|
||
|
||
try {
|
||
const payload: BatchModelInvokePublishPayload = {
|
||
ids: targetIds,
|
||
publishEnabled,
|
||
};
|
||
const res = await batchUpdateInvokePublishStatus(payload);
|
||
|
||
if (res.errorCode !== 0) {
|
||
ElMessage.error(res.message || `批量${actionText}失败`);
|
||
return;
|
||
}
|
||
|
||
applyUpdatedModels((res.data || []) as llmType[], {
|
||
preserveInvokeCodeDraft,
|
||
draftInvokeCode,
|
||
});
|
||
checkedModelIds.value = [];
|
||
ElMessage.success(`已${actionText}${scopeText}`);
|
||
emit('updated');
|
||
} finally {
|
||
isBatchPublishing.value = false;
|
||
}
|
||
};
|
||
|
||
const saveInvokeConfig = async () => {
|
||
if (!draft.id) {
|
||
return;
|
||
}
|
||
|
||
isSaving.value = true;
|
||
|
||
try {
|
||
const payload: ModelInvokeConfigPayload = {
|
||
id: draft.id,
|
||
invokeCode: draft.invokeCode.trim(),
|
||
publishEnabled: Boolean(selectedModel.value?.publishEnabled),
|
||
};
|
||
const res = await updateModelInvokeConfig(payload);
|
||
|
||
if (res.errorCode !== 0) {
|
||
ElMessage.error(res.message || '保存统一网关配置失败');
|
||
return;
|
||
}
|
||
|
||
applyUpdatedModel(res.data as llmType);
|
||
ElMessage.success(res.message || '统一网关配置已保存');
|
||
emit('updated');
|
||
} finally {
|
||
isSaving.value = false;
|
||
}
|
||
};
|
||
|
||
const resetDraft = () => {
|
||
syncDraft(selectedModel.value || null);
|
||
};
|
||
|
||
onMounted(() => {
|
||
loadPublishBaseUrl();
|
||
loadModels();
|
||
});
|
||
|
||
defineExpose({
|
||
async reloadData(preferredId?: string) {
|
||
await loadModels(preferredId);
|
||
},
|
||
async confirmBeforeLeave() {
|
||
return confirmDiscardDraft();
|
||
},
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="gateway-workspace">
|
||
<aside class="gateway-workspace__sidebar">
|
||
<div class="gateway-workspace__filters">
|
||
<ElInput
|
||
v-model.trim="filterState.keyword"
|
||
clearable
|
||
placeholder="搜索模型名、调用标识"
|
||
/>
|
||
<div class="gateway-workspace__batch">
|
||
<ElCheckbox
|
||
:model-value="isAllVisibleChecked"
|
||
:indeterminate="isPartiallyVisibleChecked"
|
||
@change="handleToggleVisibleChecked"
|
||
>
|
||
全选当前筛选
|
||
</ElCheckbox>
|
||
<ElButton
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:loading="isBatchPublishing"
|
||
:disabled="checkedCount === 0"
|
||
@click="handleBatchPublish"
|
||
>
|
||
{{ batchActionLabel }}
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isLoading" class="gateway-workspace__state">
|
||
正在加载可发布模型...
|
||
</div>
|
||
<div
|
||
v-else-if="lastErrorMessage"
|
||
class="gateway-workspace__state gateway-workspace__state--error"
|
||
>
|
||
{{ lastErrorMessage }}
|
||
</div>
|
||
<div v-else-if="modelRows.length === 0" class="gateway-workspace__empty">
|
||
<ElEmpty description="还没有聊天模型,先去已配置模型里新增一个。" />
|
||
</div>
|
||
<div
|
||
v-else-if="filteredModels.length === 0"
|
||
class="gateway-workspace__empty"
|
||
>
|
||
<ElEmpty description="没有符合条件的模型,试试调整搜索条件。" />
|
||
</div>
|
||
|
||
<div v-else class="gateway-workspace__list">
|
||
<div
|
||
v-for="model in filteredModels"
|
||
:key="model.id"
|
||
class="gateway-model-item"
|
||
:class="{ 'is-active': String(model.id) === selectedModelId }"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="handleSelectModel(model)"
|
||
@keydown.enter.prevent="handleSelectModel(model)"
|
||
@keydown.space.prevent="handleSelectModel(model)"
|
||
>
|
||
<ElCheckbox
|
||
class="gateway-model-item__checkbox"
|
||
:model-value="checkedModelIds.includes(String(model.id))"
|
||
@click.stop
|
||
@change="
|
||
(checked) =>
|
||
handleToggleModelChecked(String(model.id), Boolean(checked))
|
||
"
|
||
/>
|
||
<ModelProviderBadge
|
||
:icon="model.modelProvider?.icon || model.aiLlmProvider?.icon"
|
||
:provider-name="
|
||
model.modelProvider?.providerName ||
|
||
model.aiLlmProvider?.providerName
|
||
"
|
||
:provider-type="
|
||
model.modelProvider?.providerType ||
|
||
model.aiLlmProvider?.providerType
|
||
"
|
||
:size="44"
|
||
/>
|
||
<div class="gateway-model-item__content">
|
||
<strong>{{ model.title }}</strong>
|
||
</div>
|
||
<ElTag
|
||
:type="model.publishEnabled ? 'success' : 'info'"
|
||
effect="plain"
|
||
size="small"
|
||
>
|
||
{{ model.publishEnabled ? '已发布' : '未发布' }}
|
||
</ElTag>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="gateway-workspace__detail">
|
||
<div
|
||
v-if="!selectedModel"
|
||
class="gateway-workspace__empty gateway-workspace__empty--detail"
|
||
>
|
||
<ElEmpty description="请选择一个聊天模型开始配置统一网关。" />
|
||
</div>
|
||
|
||
<div v-else class="gateway-workspace__detail-body">
|
||
<section class="gateway-card gateway-card--hero">
|
||
<div class="gateway-hero__title">
|
||
<ModelProviderBadge
|
||
:icon="
|
||
selectedModel.modelProvider?.icon ||
|
||
selectedModel.aiLlmProvider?.icon
|
||
"
|
||
:provider-name="
|
||
selectedModel.modelProvider?.providerName ||
|
||
selectedModel.aiLlmProvider?.providerName
|
||
"
|
||
:provider-type="
|
||
selectedModel.modelProvider?.providerType ||
|
||
selectedModel.aiLlmProvider?.providerType
|
||
"
|
||
:size="48"
|
||
/>
|
||
<div class="gateway-hero__title-text">
|
||
<h3>{{ selectedModel.title }}</h3>
|
||
<span
|
||
v-if="!draft.publishEnabled"
|
||
class="gateway-card__warning-text"
|
||
>
|
||
未发布
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="gateway-card__tags">
|
||
<div class="gateway-publish-inline">
|
||
<span class="gateway-publish-inline__label">API 发布</span>
|
||
<ElSwitch
|
||
:model-value="draft.publishEnabled"
|
||
inline-prompt
|
||
active-text="开"
|
||
inactive-text="关"
|
||
:loading="isPublishing"
|
||
@change="handlePublishToggle"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<ElAlert
|
||
v-for="hint in statusHints"
|
||
:key="hint"
|
||
type="warning"
|
||
:closable="false"
|
||
:title="hint"
|
||
/>
|
||
|
||
<section class="gateway-card">
|
||
<div class="gateway-card__head">
|
||
<h4>模型映射配置</h4>
|
||
</div>
|
||
|
||
<ElForm
|
||
label-position="top"
|
||
class="gateway-form gateway-form--compact"
|
||
>
|
||
<div class="gateway-form__grid">
|
||
<ElFormItem label="上游模型 ID">
|
||
<ElInput :model-value="upstreamModelName" disabled />
|
||
</ElFormItem>
|
||
<ElFormItem label="服务商类型">
|
||
<ElInput :model-value="providerType" disabled />
|
||
</ElFormItem>
|
||
</div>
|
||
<div class="gateway-form__invoke-row">
|
||
<ElFormItem label="对外模型标识">
|
||
<ElInput
|
||
v-model.trim="draft.invokeCode"
|
||
placeholder="默认按模型 ID 生成,可手动修改"
|
||
/>
|
||
</ElFormItem>
|
||
<div class="gateway-form__actions">
|
||
<ElButton :disabled="!isInvokeCodeDirty" @click="resetDraft">
|
||
重置改动
|
||
</ElButton>
|
||
<ElButton
|
||
type="primary"
|
||
:loading="isSaving"
|
||
:disabled="!isInvokeCodeDirty"
|
||
@click="saveInvokeConfig"
|
||
>
|
||
保存配置
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
</ElForm>
|
||
</section>
|
||
|
||
<section class="gateway-card">
|
||
<div class="gateway-card__head">
|
||
<div>
|
||
<h4>调用信息</h4>
|
||
<p>统一网关固定走 OpenAI-compatible chat/completions 协议。</p>
|
||
</div>
|
||
<ElButton text size="small" @click="copyText(apiEndpoint)">
|
||
复制地址
|
||
</ElButton>
|
||
</div>
|
||
|
||
<div class="gateway-info-grid">
|
||
<div class="gateway-info-item">
|
||
<span>调用地址</span>
|
||
<strong>{{ apiEndpoint }}</strong>
|
||
</div>
|
||
<div class="gateway-info-item">
|
||
<span>鉴权 Header</span>
|
||
<strong>{{ authExample }}</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="gateway-card">
|
||
<div class="gateway-card__head">
|
||
<div>
|
||
<h4>能力说明</h4>
|
||
<p>这里展示的是统一网关可透传的当前模型能力。</p>
|
||
</div>
|
||
</div>
|
||
<div class="gateway-card__tags">
|
||
<ElTag v-for="tag in capabilityTags" :key="tag" effect="plain">
|
||
{{ tag }}
|
||
</ElTag>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="gateway-card">
|
||
<div class="gateway-card__head">
|
||
<div>
|
||
<h4>调用示例</h4>
|
||
<p>示例中的 `model` 固定使用当前配置的 invokeCode。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="gateway-example-list">
|
||
<article class="gateway-example-item">
|
||
<div class="gateway-example-item__head">
|
||
<h5>curl</h5>
|
||
<ElButton text size="small" @click="copyText(curlExample)">
|
||
复制
|
||
</ElButton>
|
||
</div>
|
||
<pre>{{ curlExample }}</pre>
|
||
</article>
|
||
<article class="gateway-example-item">
|
||
<div class="gateway-example-item__head">
|
||
<h5>JavaScript</h5>
|
||
<ElButton text size="small" @click="copyText(jsExample)">
|
||
复制
|
||
</ElButton>
|
||
</div>
|
||
<pre>{{ jsExample }}</pre>
|
||
</article>
|
||
<article class="gateway-example-item">
|
||
<div class="gateway-example-item__head">
|
||
<h5>Python</h5>
|
||
<ElButton text size="small" @click="copyText(pythonExample)">
|
||
复制
|
||
</ElButton>
|
||
</div>
|
||
<pre>{{ pythonExample }}</pre>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.gateway-workspace {
|
||
display: grid;
|
||
grid-template-columns: 320px minmax(0, 1fr);
|
||
gap: 16px;
|
||
height: calc(100vh - 240px);
|
||
min-height: 0;
|
||
}
|
||
|
||
.gateway-workspace__sidebar,
|
||
.gateway-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
padding: 18px;
|
||
background: hsl(var(--surface-panel) / 92%);
|
||
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.gateway-workspace__sidebar {
|
||
height: 100%;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.gateway-card__head h4,
|
||
.gateway-card--hero h3,
|
||
.gateway-example-item__head h5 {
|
||
margin: 0;
|
||
color: hsl(var(--text-strong));
|
||
}
|
||
|
||
.gateway-card__head p,
|
||
.gateway-card--hero p,
|
||
.gateway-form__switch-row span,
|
||
.gateway-workspace__state {
|
||
margin: 6px 0 0;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.gateway-card__warning-text {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
color: var(--el-color-warning);
|
||
}
|
||
|
||
.gateway-workspace__filters {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.gateway-workspace__batch {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
background: hsl(var(--surface-contrast-soft) / 48%);
|
||
border: 1px solid hsl(var(--divider-faint) / 40%);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.gateway-workspace__list {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.gateway-model-item {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 12px;
|
||
text-align: left;
|
||
background: hsl(var(--surface-panel));
|
||
border: 1px solid hsl(var(--divider-faint) / 50%);
|
||
border-radius: 14px;
|
||
transition:
|
||
border-color 0.2s ease,
|
||
box-shadow 0.2s ease,
|
||
transform 0.2s ease;
|
||
}
|
||
|
||
.gateway-model-item:hover {
|
||
border-color: hsl(var(--primary) / 30%);
|
||
box-shadow: 0 16px 30px -28px hsl(var(--foreground) / 34%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.gateway-model-item.is-active {
|
||
border-color: hsl(var(--primary) / 42%);
|
||
background: hsl(var(--primary) / 7%);
|
||
}
|
||
|
||
.gateway-model-item__content {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
min-width: 0;
|
||
}
|
||
|
||
.gateway-model-item__checkbox {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.gateway-model-item__content strong {
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 24px;
|
||
font-size: 14px;
|
||
line-height: 1.2;
|
||
color: hsl(var(--text-strong));
|
||
}
|
||
|
||
.gateway-model-item__content span {
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: hsl(var(--text-muted));
|
||
word-break: break-all;
|
||
}
|
||
|
||
.gateway-workspace__detail {
|
||
height: 100%;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.gateway-workspace__detail-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.gateway-card--hero {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.gateway-hero__title {
|
||
display: flex;
|
||
gap: 14px;
|
||
align-items: center;
|
||
}
|
||
|
||
.gateway-hero__title-text {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.gateway-hero__title-text h3 {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.gateway-card__head,
|
||
.gateway-example-item__head {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.gateway-card__tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.gateway-publish-inline {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.gateway-publish-inline__label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.gateway-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.gateway-form--compact {
|
||
gap: 12px;
|
||
}
|
||
|
||
.gateway-form__grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px 12px;
|
||
}
|
||
|
||
.gateway-form__switch-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
min-height: 32px;
|
||
}
|
||
|
||
.gateway-form__invoke-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.gateway-form__invoke-row :deep(.el-form-item) {
|
||
flex: 1;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.gateway-form__actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.gateway-info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.gateway-info-item,
|
||
.gateway-example-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding: 14px;
|
||
background: hsl(var(--surface-contrast-soft) / 56%);
|
||
border: 1px solid hsl(var(--divider-faint) / 48%);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.gateway-info-item span {
|
||
font-size: 12px;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.gateway-info-item strong {
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: hsl(var(--text-strong));
|
||
word-break: break-all;
|
||
}
|
||
|
||
.gateway-example-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.gateway-example-item pre {
|
||
margin: 0;
|
||
padding: 14px;
|
||
overflow: auto;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
color: hsl(var(--text-strong));
|
||
background: hsl(var(--surface-panel));
|
||
border: 1px solid hsl(var(--divider-faint) / 40%);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.gateway-workspace__empty,
|
||
.gateway-workspace__state {
|
||
display: flex;
|
||
flex: 1;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.gateway-workspace__empty--detail {
|
||
min-height: 420px;
|
||
padding: 18px;
|
||
background: hsl(var(--surface-panel) / 92%);
|
||
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.gateway-workspace__state--error {
|
||
color: hsl(var(--danger));
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.gateway-workspace {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.gateway-card--hero,
|
||
.gateway-card__head,
|
||
.gateway-example-item__head {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.gateway-form__grid,
|
||
.gateway-info-grid {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
.gateway-form__switch-row {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.gateway-form__invoke-row {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.gateway-publish-inline {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.gateway-hero__title {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|