初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { NButton, useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { listAiChannels, listAiKeys, listAiModels } from '@/api/aigateway'
import AiChannelPanel from '@/components/ai/AiChannelPanel.vue'
import AiKeyPanel from '@/components/ai/AiKeyPanel.vue'
import { useAsync } from '@/composables/useAsync'
const message = useMessage()
const keys = useAsync(listAiKeys)
const channels = useAsync(listAiChannels)
const models = useAsync(listAiModels)
const baseUrl = `${window.location.origin}/ai/v1`
const endpoints = [
{ method: 'POST', path: '/chat/completions', note: 'OpenAI · 流式/非流式' },
{ method: 'POST', path: '/responses', note: 'OpenAI Responses · 无状态子集' },
{ method: 'POST', path: '/messages', note: 'Claude · 流式/非流式' },
{ method: 'POST', path: '/embeddings', note: 'OpenAI Embeddings · 向量化' },
{ method: 'GET', path: '/models', note: '模型列表(按密钥分组过滤)' },
]
/** 厂商显示名:模型名前缀 → 品牌名 */
const VENDOR_LABEL: Record<string, string> = {
meta: 'Meta', xai: 'xAI', google: 'Google', openai: 'OpenAI', cohere: 'Cohere',
}
/** 可用模型按厂商分组(取模型名前缀,厂商内按名称排序) */
const modelGroups = computed(() => {
const byVendor = new Map<string, string[]>()
for (const m of models.data.value ?? []) {
const vendor = m.id.split('.', 1)[0] || m.owned_by || '其他'
const list = byVendor.get(vendor) ?? []
list.push(m.id)
byVendor.set(vendor, list)
}
return [...byVendor.entries()]
.sort((a, b) => b[1].length - a[1].length)
.map(([vendor, ids]) => ({ vendor, label: VENDOR_LABEL[vendor] ?? vendor, ids: ids.sort() }))
})
/** 手风琴:同时只展开一个厂商,配合展开区限高控制卡片总高度 */
const expandedVendor = ref<string | null>(null)
function toggleVendor(vendor: string) {
expandedVendor.value = expandedVendor.value === vendor ? null : vendor
}
/** 渠道现有分组去重,供密钥弹窗快捷选择 */
const groups = computed(() => [...new Set((channels.data.value ?? []).map((c) => c.group).filter(Boolean))])
async function copyBase() {
try {
await navigator.clipboard.writeText(baseUrl)
message.success('已复制 Base URL')
} catch {
message.error('复制失败,请手动选择复制')
}
}
async function copyModel(id: string) {
try {
await navigator.clipboard.writeText(id)
message.success(`已复制:${id}`)
} catch {
message.error('复制失败')
}
}
function refreshChannels() {
void channels.run({ silent: true })
void models.run({ silent: true })
}
</script>
<template>
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
<div class="flex flex-wrap items-baseline gap-2.5">
<h1 class="page-title">AI 网关</h1>
<span class="text-[13px] text-ink-3">
把有 Generative AI 配额的租户接入号池,对外提供 OpenAI / Claude 格式的标准接口
</span>
</div>
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3 text-sm font-semibold">接入信息</div>
<div class="px-4.5 py-2">
<div class="flex items-center gap-2 border-b border-line-soft py-2 text-[13px]">
<span class="w-13 flex-none text-[10.5px] font-bold text-ok">BASE</span>
<span class="mono min-w-0 flex-1 truncate">{{ baseUrl }}</span>
<NButton size="tiny" quaternary type="primary" @click="copyBase">复制</NButton>
</div>
<div
v-for="ep in endpoints"
:key="ep.path"
class="flex items-center gap-2 border-b border-line-soft py-2 text-[13px] last:border-b-0"
>
<span class="w-13 flex-none text-[10.5px] font-bold text-ok">{{ ep.method }}</span>
<span class="mono min-w-0 flex-1">{{ ep.path }}</span>
<span class="flex-none text-xs text-ink-3">{{ ep.note }}</span>
</div>
</div>
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
鉴权:Authorization: Bearer 密钥 x-api-key: 密钥 · 两种头都支持,OpenAI / Anthropic SDK 可直接指向 Base URL
</div>
</div>
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3">
<div>
<div class="text-sm font-semibold">可用模型</div>
<div class="mt-0.5 text-xs text-ink-3">按厂商分组;点击厂商展开,点击模型名复制</div>
</div>
<span class="text-xs text-ink-3">{{ models.data.value?.length ?? '…' }} </span>
</div>
<div class="px-4.5 py-1.5">
<div
v-for="g in modelGroups"
:key="g.vendor"
class="border-b border-line-soft py-1 last:border-b-0"
>
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 py-1.5 text-left"
@click="toggleVendor(g.vendor)"
>
<svg
class="h-3 w-3 flex-none text-ink-3 transition-transform"
:class="expandedVendor === g.vendor ? 'rotate-90' : ''"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span class="text-[13px] font-medium">{{ g.label }}</span>
<span class="text-xs text-ink-3">{{ g.ids.length }} </span>
</button>
<div
v-if="expandedVendor === g.vendor"
class="flex max-h-36 flex-wrap content-start gap-1.5 overflow-y-auto pb-2 pl-5"
>
<button
v-for="id in g.ids"
:key="id"
type="button"
class="mono cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-[11.5px] hover:border-accent hover:text-accent"
:title="`复制 ${id}`"
@click="copyModel(id)"
>
{{ id }}
</button>
</div>
</div>
<div v-if="models.data.value && !models.data.value.length" class="py-2 text-xs text-ink-3">
暂无可用模型:请先添加渠道并探测(需租户订阅 GenAI 提供区域)
</div>
</div>
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
模型由渠道探测 / 同步自动发现;多模态图片输入视模型而定;Cohere 系工具调用为扁平参数有损降级不支持图片
</div>
</div>
</div>
<AiKeyPanel
:keys="keys.data.value ?? []"
:loading="keys.loading.value"
:groups="groups"
@changed="keys.run({ silent: true })"
/>
<AiChannelPanel
:channels="channels.data.value ?? []"
:loading="channels.loading.value"
@changed="refreshChannels"
/>
</div>
</template>