feat: 三协议互转网关 + 鉴权修复 + 管理端增强

后端
- 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转,
  以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测)
- gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式),
  streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀
- gateway: 新增 SetUsageRecorder 注入异步用量记录器
- auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401;
  修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应
- usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段
- channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数
- api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容)

前端
- 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer
- 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts
- 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
This commit is contained in:
Sakurasan
2026-08-31 22:29:09 +08:00
parent e472ed93d5
commit f81b364436
34 changed files with 5171 additions and 179 deletions
+2 -2
View File
@@ -47,9 +47,9 @@ const iconForType = (type: ToastType) => {
const typeClasses = (type: ToastType) => {
switch (type) {
case 'success':
return 'border-success/20 bg-success/10 text-success-content dark:border-success/30 dark:bg-success/15';
return 'border-success bg-success/15 text-success';
case 'error':
return 'border-error/20 bg-error/10 text-error-content dark:border-error/30 dark:bg-error/15';
return 'border-error bg-error/15 text-error';
default:
return 'border-base-300 bg-base-100 text-base-content';
}
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent' }>(), {
variant: 'neutral',
})
</script>
<template>
<span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] leading-5"
:class="{
neutral: 'bg-base-200 text-base-content',
ok: 'bg-success/10 text-success',
warn: 'bg-warning/10 text-warning',
err: 'bg-error/10 text-error',
accent: 'bg-primary/10 text-primary',
}[variant]"
>
<span
v-if="variant !== 'neutral'"
class="size-1.5 rounded-full"
:class="{
ok: 'bg-success',
warn: 'bg-warning',
err: 'bg-error',
accent: 'bg-primary',
}[variant]"
/>
<slot />
</span>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'danger'
size?: 'sm' | 'md'
loading?: boolean
disabled?: boolean
}>(),
{ variant: 'primary', size: 'md', loading: false, disabled: false },
)
</script>
<template>
<button
:disabled="disabled || loading"
class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none"
:class="[
size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm',
variant === 'primary' && 'bg-primary text-primary-content hover:bg-primary/90',
variant === 'ghost' && 'border border-base-300/60 text-base-content hover:bg-base-200/50',
variant === 'danger' && 'border border-error text-error hover:bg-error/10',
]"
>
<span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<slot />
</button>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string
modelValue?: string | number
type?: string
placeholder?: string
hint?: string
error?: string
autocomplete?: string
disabled?: boolean
maxlength?: number
}>(),
{ type: 'text', modelValue: '', disabled: false },
)
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
</script>
<template>
<label class="block">
<span v-if="label" class="mb-1.5 block text-xs font-medium text-base-content/50">{{ label }}</span>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:autocomplete="autocomplete"
:disabled="disabled"
:maxlength="maxlength"
class="h-10 w-full rounded-md border border-base-300/60 bg-base-100 px-3 text-sm text-base-content placeholder-base-content/40 outline-none transition focus:border-primary focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
:class="error && 'border-error focus:border-error focus:ring-error'"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
/>
<span v-if="hint && !error" class="mt-1.5 block text-xs text-base-content/50">{{ hint }}</span>
<span v-if="error" class="mt-1.5 block text-xs text-error">{{ error }}</span>
</label>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { X } from '@lucide/vue'
const props = withDefaults(
defineProps<{
open: boolean
title?: string
width?: string
}>(),
{ width: 'max-w-md' },
)
const emit = defineEmits<{ close: [] }>()
const panel = ref<HTMLElement | null>(null)
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) emit('close')
}
onMounted(() => window.addEventListener('keydown', onKey))
onUnmounted(() => window.removeEventListener('keydown', onKey))
watch(
() => props.open,
async (open) => {
document.body.style.overflow = open ? 'hidden' : ''
if (open) {
await nextTick()
panel.value?.focus()
}
},
)
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-150"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-[12vh] backdrop-blur-sm"
@mousedown.self="emit('close')"
>
<Transition
enter-active-class="transition-transform duration-150"
enter-from-class="scale-[0.97] opacity-0"
leave-active-class="transition-transform duration-150"
leave-to-class="scale-[0.97] opacity-0"
>
<div
v-if="open"
ref="panel"
role="dialog"
aria-modal="true"
:aria-label="title || '对话框'"
tabindex="-1"
class="card w-full bg-base-100 shadow-xl outline-none"
:class="width"
>
<div class="flex items-center justify-between border-b border-base-300/60 px-5 py-3.5">
<h3 class="text-sm font-semibold text-base-content">{{ title }}</h3>
<button class="rounded-md p-1 text-base-content/40 hover:bg-base-200 hover:text-base-content" aria-label="关闭" @click="emit('close')">
<X :size="16" />
</button>
</div>
<div class="px-5 py-4">
<slot />
</div>
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-base-300/60 px-5 py-3.5">
<slot name="footer" />
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
+27
View File
@@ -0,0 +1,27 @@
// 协议格式显示名与选项
export const PROTOCOL_NAMES: Record<string, string> = {
chat: 'OpenAI Chat Completions',
responses: 'OpenAI Responses API',
messages: 'Anthropic Messages',
}
// 渠道表格用的短标识
export const PROTOCOL_SHORT: Record<string, string> = {
chat: 'chat/completions',
responses: 'responses',
messages: 'messages',
}
export function protocolShort(p: string): string {
return PROTOCOL_SHORT[p] ?? p
}
export const PROTOCOL_OPTIONS: { value: string; label: string }[] = [
{ value: 'chat', label: 'OpenAI Chat Completions' },
{ value: 'responses', label: 'OpenAI Responses API' },
{ value: 'messages', label: 'Anthropic Messages' },
]
export function protocolName(p: string): string {
return PROTOCOL_NAMES[p] ?? p
}
+101
View File
@@ -8,6 +8,8 @@ export type Channel = {
name: string
provider: string
base_url: string
base_urls?: Record<string, string>
api_key_masked?: string
weight: number
priority: number
timeout_ms: number
@@ -31,6 +33,14 @@ export type NewChannelPayload = {
formats?: string[]
}
export type ChannelModelBinding = {
id: number
model_id: number
model_name: string
upstream_model: string
weight: number
}
export const useChannelStore = defineStore('channel', () => {
const loading = ref(false);
const error = ref<string | null>(null);
@@ -125,6 +135,91 @@ export const useChannelStore = defineStore('channel', () => {
}
};
// Admin API methods
const testChannel = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/admin/channels/${id}/test`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to test channel';
throw err;
} finally {
loading.value = false;
}
};
const fetchRemoteModels = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/admin/channels/${id}/models/remote`);
return response.data.data ?? [];
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to fetch remote models';
throw err;
} finally {
loading.value = false;
}
};
const fetchChannelModels = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/admin/channels/${id}/models`);
return response.data.data ?? [];
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to fetch channel models';
throw err;
} finally {
loading.value = false;
}
};
const addChannelModel = async (id: number | string, data: { model_id: number; upstream_model: string; weight?: number }) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/admin/channels/${id}/models`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to add model';
throw err;
} finally {
loading.value = false;
}
};
const updateChannelModel = async (channelId: number | string, bindingId: number | string, data: { upstream_model?: string; weight?: number }) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.patch(`/admin/channels/${channelId}/models/${bindingId}`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to update binding';
throw err;
} finally {
loading.value = false;
}
};
const deleteChannelModel = async (channelId: number | string, bindingId: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/admin/channels/${channelId}/models/${bindingId}`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete binding';
throw err;
} finally {
loading.value = false;
}
};
return {
loading, error,
channel, channels, totalChannels,
@@ -134,5 +229,11 @@ export const useChannelStore = defineStore('channel', () => {
updateChannel,
deleteChannel,
batchChannels,
testChannel,
fetchRemoteModels,
fetchChannelModels,
addChannelModel,
updateChannelModel,
deleteChannelModel,
};
});
+171
View File
@@ -0,0 +1,171 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { AxiosResponse } from 'axios';
import request from '@/api/client';
export type Model = {
id: number
name: string
display_name?: string
input_price: number
output_price: number
cache_read_price: number
enabled: boolean
sort: number
channels?: ModelBinding[]
used?: boolean
needs_pricing?: boolean
denied?: boolean
created_at?: string
updated_at?: string
[key: string]: unknown
}
export type ModelBinding = {
id: number
channel_id: number
channel_name: string
upstream_model: string
weight: number
}
export type NewModelPayload = {
name: string
display_name?: string
input_price?: number
output_price?: number
cache_read_price?: number
sort?: number
enabled?: boolean
}
export type ModelSummary = {
total: number
unpriced: number
missing: OrphanBinding[]
denied_count: number
}
export type OrphanBinding = {
channel: string
model_id: number
upstream_model: string
}
export const useModelStore = defineStore('model', () => {
const loading = ref(false);
const error = ref<string | null>(null);
const models = ref<Model[]>([]);
const summary = ref<ModelSummary | null>(null);
const fetchModels = async () => {
loading.value = true;
error.value = null;
try {
const response = await request.get('/admin/models');
models.value = response.data.data ?? [];
summary.value = response.data.summary ?? null;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to fetch models';
throw err;
} finally {
loading.value = false;
}
};
const createModel = async (data: NewModelPayload) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post('/admin/models', data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create model';
throw err;
} finally {
loading.value = false;
}
};
const updateModel = async (id: number | string, data: Partial<Model>) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.put(`/admin/models/${id}`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to update model';
throw err;
} finally {
loading.value = false;
}
};
const deleteModel = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/admin/models/${id}`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete model';
throw err;
} finally {
loading.value = false;
}
};
const deleteUnusedModels = async () => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete('/admin/models/unused');
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete unused models';
throw err;
} finally {
loading.value = false;
}
};
const createModelBinding = async (modelId: number | string, data: { channel_id: number; upstream_model: string; weight?: number }) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/admin/models/${modelId}/bindings`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create binding';
throw err;
} finally {
loading.value = false;
}
};
const deleteModelBinding = async (modelId: number | string, bindingId: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/admin/models/${modelId}/bindings/${bindingId}`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete binding';
throw err;
} finally {
loading.value = false;
}
};
return {
loading, error,
models, summary,
fetchModels,
createModel,
updateModel,
deleteModel,
deleteUnusedModels,
createModelBinding,
deleteModelBinding,
};
});
+56
View File
@@ -115,3 +115,59 @@ export type NewUserPayload = {
unlimited_quota?: boolean
language?: string
}
// Channel 渠道管理
export interface Channel {
id: number
name: string
provider: 'openai' | 'anthropic' | 'compatible'
formats: string[] // chat | responses | messages
base_url: string
base_urls?: Record<string, string> | null
api_key_masked: string
weight: number
priority: number
timeout_ms: number
max_concurrency: number
health_status: string
enabled: boolean
created_at: string
}
export interface ChannelModelMapping {
id: number
model_id: number
model_name: string
upstream_model: string
weight: number
}
// Model 模型定价
export interface ModelBinding {
id: number
channel_id: number
channel_name: string
upstream_model: string
weight: number
}
export interface Model {
id: number
name: string
input_price: number
output_price: number
cache_read_price: number
enabled: boolean
sort: number
channels: ModelBinding[]
used?: boolean
needs_pricing?: boolean
denied?: boolean
}
export interface ModelSummary {
total: number
unpriced: number
missing: { channel: string; model_id: number; upstream_model: string }[]
denied_count: number
}
+7 -2
View File
@@ -6,6 +6,8 @@ import {
KeyRoundIcon,
SettingsIcon,
GlobeIcon,
BoxesIcon,
SlidersHorizontalIcon,
} from '@lucide/vue'
export type MenuLink = { label: string; to: string; icon?: Component }
@@ -48,8 +50,9 @@ export const routes: RouteRecordRaw[] = [
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } },
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: '新建用户' } },
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } },
{ path: 'channels/view', name: 'ChannelView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } },
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/ChannelsView.vue'), meta: { title: '渠道管理' } },
{ path: 'models', name: 'Models', component: () => import('@/views/dashboard/Models.vue'), meta: { title: '模型定价' } },
{ path: 'config', name: 'SystemConfig', component: () => import('@/views/dashboard/SystemConfig.vue'), meta: { title: '系统配置' } },
],
},
{
@@ -76,4 +79,6 @@ export const consoleMenu: MenuLink[] = [
export const adminMenu: MenuLink[] = [
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
{ label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon },
{ label: '模型定价', to: '/dashboard/manager/models', icon: BoxesIcon },
{ label: '系统配置', to: '/dashboard/manager/config', icon: SlidersHorizontalIcon },
]
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { RefreshCw, Plus, X } from '@lucide/vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
import Button from '@/components/ui/Button.vue'
import type { Channel, ChannelModelMapping } from '@/types'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
const props = defineProps<{ channel: Channel }>()
const { setToast } = useToast()
const mappings = ref<ChannelModelMapping[]>([])
const remote = ref<string[]>([])
const selected = ref<string[]>([])
const loading = ref(false)
const fetched = ref(false)
const addForm = reactive({ custom_name: '', upstream_model: '' })
async function load() {
try {
const { data } = await request.get(`/admin/channels/${props.channel.id}/models`)
mappings.value = data.data?.items || data.data || []
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function fetchRemote() {
loading.value = true
try {
const { data } = await request.get(`/admin/channels/${props.channel.id}/models/remote`)
remote.value = data.data || []
selected.value = []
fetched.value = true
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
loading.value = false
}
}
async function addSelected() {
let added = 0
for (const name of selected.value) {
try {
await request.post(`/admin/channels/${props.channel.id}/models`, {
upstream_model: name,
})
added++
} catch {
/* 单个失败不中断 */
}
}
selected.value = []
setToast(added ? `已添加 ${added} 个模型` : '所选均已添加', 'success')
await load()
await fetchRemote()
}
async function addManual() {
if (!addForm.upstream_model.trim()) return
try {
await request.post(`/admin/channels/${props.channel.id}/models`, {
upstream_model: addForm.upstream_model.trim(),
custom_name: addForm.custom_name.trim() || undefined,
})
setToast('已添加', 'success')
addForm.custom_name = ''
addForm.upstream_model = ''
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function saveUpstream(b: ChannelModelMapping) {
try {
await request.patch(`/admin/channels/${props.channel.id}/models/${b.id}`, {
upstream_model: b.upstream_model,
})
setToast('已更新', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function remove(b: ChannelModelMapping) {
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
try {
await request.delete(`/admin/channels/${props.channel.id}/models/${b.id}`)
setToast('已解除', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
onMounted(load)
</script>
<template>
<div class="space-y-3">
<!-- 已允许的模型 -->
<div>
<p class="mb-1.5 text-xs font-medium text-base-content/50">已允许的模型({{ mappings.length }})</p>
<div v-if="mappings.length" class="flex flex-wrap gap-2">
<div
v-for="b in mappings"
:key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-base-300/60 bg-base-100 px-2 py-1 font-mono text-[11px] text-base-content/60"
>
<span class="text-base-content">{{ b.model_name }}</span>
<span class="opacity-60">→</span>
<input
v-model="b.upstream_model"
class="w-28 rounded border border-transparent bg-transparent px-1 text-[11px] text-primary outline-none transition focus:border-primary/50 focus:bg-base-200/50"
@change="saveUpstream(b)"
/>
<button class="text-base-content/40 hover:text-error" aria-label="解除" @click="remove(b)">
<X :size="12" />
</button>
</div>
</div>
<p v-else class="text-xs text-base-content/50">尚未允许任何模型</p>
</div>
<!-- 从接口拉取 + 勾选 -->
<div class="border-t border-base-300/60 pt-3">
<div class="mb-1.5 flex items-center justify-between">
<p class="text-xs font-medium text-base-content/50">从接口拉取模型</p>
<Button size="sm" variant="ghost" :loading="loading" @click="fetchRemote">
<RefreshCw :size="13" />
拉取
</Button>
</div>
<div v-if="remote.length" class="flex max-h-36 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in remote"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[11px] text-base-content/60 transition select-none"
:class="selected.includes(m) ? 'border-primary bg-primary/10 text-base-content' : 'border-base-300/60 hover:border-base-content/30'"
>
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-primary" />
{{ m }}
</label>
</div>
<div v-if="remote.length" class="mt-2">
<Button size="sm" @click="addSelected">
<Plus :size="13" />
添加所选({{ selected.length }})
</Button>
</div>
<p v-else-if="!loading" class="text-xs text-base-content/50">
{{ remote.length === 0 && fetched ? '接口返回的模型均已允许,无新增候选' : '点「拉取」获取渠道接口返回的新模型,勾选需要的加入' }}
</p>
</div>
<!-- 手动添加 -->
<div class="flex items-center gap-2 border-t border-base-300/60 pt-3">
<input
v-model="addForm.custom_name"
placeholder="自定义名称(可选)"
class="h-8 min-w-0 flex-1 rounded-md border border-base-300/60 bg-base-100 px-2 font-mono text-xs outline-none focus:border-primary"
@keyup.enter="addManual"
/>
<input
v-model="addForm.upstream_model"
placeholder="上游模型名"
class="h-8 min-w-0 flex-1 rounded-md border border-base-300/60 bg-base-100 px-2 font-mono text-xs outline-none focus:border-primary"
@keyup.enter="addManual"
/>
<Button size="sm" class="shrink-0" @click="addManual">
<Plus :size="13" />
添加
</Button>
</div>
</div>
</template>
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ChevronDown, Zap, Pencil, Trash2, Layers } from '@lucide/vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
import ChannelModelsDrawer from '@/views/dashboard/ChannelModelsDrawer.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Channel } from '@/types'
const { setToast } = useToast()
const channels = ref<Channel[]>([])
const editOpen = ref(false)
const editing = ref<Channel | null>(null)
const saving = ref(false)
const busyId = ref<number | null>(null)
const expandedId = ref<number | null>(null)
function toggleDrawer(ch: Channel) {
expandedId.value = expandedId.value === ch.id ? null : ch.id
}
const form = reactive({
name: '',
formats: ['chat'] as string[],
base_url: '',
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
api_key: '',
weight: 1,
priority: 0,
timeout_ms: 120000,
max_concurrency: 16,
enabled: true,
})
async function load() {
try {
const { data } = await request.get('/admin/channels')
channels.value = data.data.items || data.data
} catch (e) {
setToast(errMsg(e), 'error')
}
}
function openCreate() {
editing.value = null
Object.assign(form, {
name: '', formats: ['chat'], base_url: '',
base_urls: { chat: '', responses: '', messages: '' },
api_key: '',
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
})
editOpen.value = true
}
function openEdit(ch: Channel) {
editing.value = ch
Object.assign(form, {
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
base_url: ch.base_url,
base_urls: {
chat: ch.base_urls?.chat ?? '',
responses: ch.base_urls?.responses ?? '',
messages: ch.base_urls?.messages ?? '',
},
api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
})
editOpen.value = true
}
async function save() {
if (form.formats.length === 0) {
setToast('请至少选择一种 API 格式', 'error')
return
}
saving.value = true
const payload = {
...form,
weight: Number(form.weight),
priority: Number(form.priority),
timeout_ms: Number(form.timeout_ms),
max_concurrency: Number(form.max_concurrency),
}
try {
if (editing.value) {
await request.put(`/admin/channels/${editing.value.id}`, payload)
setToast('渠道已更新', 'success')
} else {
await request.post('/admin/channels', payload)
setToast('渠道已创建', 'success')
}
editOpen.value = false
await load()
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
async function remove(ch: Channel) {
if (!confirm(`删除渠道 ${ch.name}?关联的模型绑定也会清除。`)) return
try {
await request.delete(`/admin/channels/${ch.id}`)
setToast('渠道已删除', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function testChannel(ch: Channel) {
busyId.value = ch.id
try {
await request.post(`/admin/channels/${ch.id}/test`)
setToast(`渠道 ${ch.name} 连接正常`, 'success')
} catch (e) {
setToast(`连接失败: ${errMsg(e)}`, 'error')
} finally {
busyId.value = null
await load()
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-base-content/60">接入上游服务,API Key 加密存储</p>
</div>
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
</div>
<!-- 移动端:卡片列表 -->
<div class="space-y-3 md:hidden">
<div v-for="ch in channels" :key="ch.id" class="card border border-base-300/60 bg-base-100 p-4 shadow-sm" :class="ch.enabled ? 'border-l-2 border-l-success' : ''">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-sm font-medium">{{ ch.name }}</p>
<div class="mt-1.5 flex flex-wrap gap-1">
<code
v-for="f in ch.formats || []"
:key="f"
class="rounded bg-base-200 px-1.5 py-0.5 font-mono text-[10px] text-base-content/60"
>{{ protocolShort(f) }}</code>
</div>
</div>
<div class="flex shrink-0 gap-1.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
</div>
</div>
<p class="mt-2 truncate font-mono text-[11px] text-base-content/60">{{ ch.base_url }}</p>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-base-300/60 pt-3">
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-primary" :disabled="busyId === ch.id" @click="testChannel(ch)">
<Zap :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-primary hover:text-primary/80" @click="toggleDrawer(ch)">
<Layers :size="13" />
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-base-content" @click="openEdit(ch)">
<Pencil :size="13" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-error" @click="remove(ch)">
<Trash2 :size="13" />
删除
</button>
</div>
<div v-if="expandedId === ch.id" class="mt-3 border-t border-base-300/60 pt-3">
<ChannelModelsDrawer :channel="ch" />
</div>
</div>
<p v-if="channels.length === 0" class="card border border-base-300/60 bg-base-100 px-4 py-10 text-center text-sm text-base-content/60">
还没有渠道,点击「添加渠道」
</p>
</div>
<!-- 桌面端:表格 -->
<div class="card hidden border border-base-300/60 bg-base-100 shadow-sm md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[820px]">
<thead>
<tr class="border-b border-base-300/60 text-left text-xs text-base-content/50">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
<th scope="col" class="px-4 py-2.5 font-medium">启用</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<template v-for="ch in channels" :key="ch.id">
<tr class="border-b border-base-300/40 last:border-0 hover:bg-base-200/50" :style="ch.enabled ? { borderLeft: '2px solid oklch(var(--p))' } : {}">
<td class="px-4 py-2.5">
<button class="inline-flex items-center gap-1.5 transition hover:text-primary" @click="toggleDrawer(ch)">
<span class="truncate">{{ ch.name }}</span>
<ChevronDown :size="12" class="shrink-0 text-base-content/50 transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
</button>
</td>
<td class="px-4 py-2.5">
<div class="flex flex-col gap-0.5">
<code
v-for="f in ch.formats || []"
:key="f"
class="font-mono text-[11px] leading-4 text-base-content/60"
>{{ protocolShort(f) }}</code>
</div>
</td>
<td class="max-w-[220px] truncate px-4 py-2.5 font-mono text-xs text-base-content/60">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-base-content/60">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
</td>
<td class="px-4 py-2.5 text-xs text-base-content/60">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-primary" :disabled="busyId === ch.id" @click="testChannel(ch)">
<Zap :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-base-content" @click="openEdit(ch)">
<Pencil :size="13" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-error" @click="remove(ch)">
<Trash2 :size="13" />
删除
</button>
</div>
</td>
</tr>
<tr v-if="expandedId === ch.id" class="bg-base-200/30">
<td colspan="7" class="px-4 py-3">
<ChannelModelsDrawer :channel="ch" />
</td>
</tr>
</template>
<tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-base-content/60">还没有渠道,点击「添加渠道」</td>
</tr>
</tbody>
</table>
</div>
</div>
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="名称" placeholder="openai" />
<div>
<span class="mb-1.5 block text-xs font-medium text-base-content/50">支持的 API 格式</span>
<div class="flex flex-wrap gap-2">
<label
v-for="opt in PROTOCOL_OPTIONS"
:key="opt.value"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="form.formats.includes(opt.value) ? 'border-primary bg-primary/10' : 'border-base-300 text-base-content/60 hover:border-base-content/30'"
>
<input
v-model="form.formats"
type="checkbox"
:value="opt.value"
class="size-3.5 accent-primary"
/>
{{ opt.label }}
</label>
</div>
<p class="mt-1.5 text-xs text-base-content/50">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div>
<Input
v-model="form.base_url"
label="Base URL(可选)"
placeholder="https://api.openai.com/v1"
:maxlength="255"
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
/>
<div class="space-y-3 rounded-md border border-base-300/60 p-3">
<p class="text-xs font-medium text-base-content/50">分协议 Base URL(可选,如智谱三种格式不同)</p>
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" :maxlength="255" />
<p class="text-xs text-base-content/50">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
</div>
<Input
v-model="form.api_key"
label="上游 API Key"
:placeholder="editing ? '留空则不修改' : 'sk-...'"
/>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input v-model="form.weight" label="权重" type="number" />
<Input v-model="form.priority" label="优先级" type="number" />
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
</div>
<div class="flex items-center justify-between rounded-md border border-base-300/60 p-3">
<div>
<p class="text-sm font-medium">启用渠道</p>
<p class="text-xs text-base-content/50">禁用后该渠道不会被用于请求转发</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.enabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="form.enabled ? 'bg-primary' : 'bg-base-200'"
@click="form.enabled = !form.enabled"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="form.enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
</div>
</template>
+148 -1
View File
@@ -87,6 +87,10 @@
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
<button type="button" @click="goBack" class="btn btn-ghost btn-sm">Back</button>
<button type="button" @click="testChannel" class="btn btn-warning btn-sm" :disabled="testing">
<span v-if="testing" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Test Connection
</button>
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Save Changes
@@ -94,6 +98,47 @@
</div>
</form>
</div>
<!-- Model Bindings -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body gap-4 p-4 sm:p-6">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Model Bindings</h2>
<button class="btn btn-primary btn-sm" @click="openAddModelModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Add Model
</button>
</div>
<div v-if="bindings.length > 0" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th>Model Name</th>
<th>Upstream Model</th>
<th class="text-right">Weight</th>
<th class="text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="b in bindings" :key="b.id" class="border-base-300/40">
<td class="font-medium">{{ b.model_name }}</td>
<td class="font-mono text-xs">{{ b.upstream_model }}</td>
<td class="text-right">{{ b.weight }}</td>
<td class="text-right">
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteBinding(b)"
aria-label="Delete binding">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="py-6 text-center text-sm text-base-content/50">
No model bindings configured.
</div>
</div>
</div>
</div>
<!-- Loading state -->
@@ -104,22 +149,59 @@
</div>
</div>
</div>
<!-- Add Model Modal -->
<dialog ref="addModelModalRef" class="modal">
<div class="modal-box max-w-lg px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<h3 class="mb-4 text-lg font-bold">Add Model Binding</h3>
<form @submit.prevent="addModelBinding" class="space-y-4">
<label class="floating-label">
<span>Model ID *</span>
<input v-model.number="newBinding.model_id" type="number" placeholder="Model ID" class="input w-full" required />
</label>
<label class="floating-label">
<span>Upstream Model Name *</span>
<input v-model="newBinding.upstream_model" type="text" placeholder="e.g. gpt-4o" class="input w-full" required />
</label>
<label class="floating-label">
<span>Weight</span>
<input v-model.number="newBinding.weight" type="number" min="1" placeholder="1" class="input w-full" />
</label>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="closeAddModelModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="addingModel">
{{ addingModel ? 'Adding...' : 'Add' }}
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useChannelStore, type Channel } from '../../stores/channel';
import { useChannelStore, type Channel, type ChannelModelBinding } from '../../stores/channel';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useToast } from '@/composables/toast';
import { PlusIcon, TrashIcon } from '@lucide/vue';
const route = useRoute();
const router = useRouter();
const channelStore = useChannelStore();
const { setToast } = useToast();
const updating = ref(false);
const testing = ref(false);
const api_key = ref('');
const bindings = ref<ChannelModelBinding[]>([]);
const channelId = computed(() => route.query.id);
const ch = computed(() => channelStore.channel);
@@ -127,9 +209,16 @@ const ch = computed(() => channelStore.channel);
onMounted(async () => {
if (channelId.value) {
await channelStore.fetchChannel(channelId.value as string);
await fetchBindings();
}
});
const fetchBindings = async () => {
if (channelId.value) {
bindings.value = await channelStore.fetchChannelModels(channelId.value as string);
}
};
const toggleEnabled = () => {
if (!ch.value) return;
ch.value.enabled = !ch.value.enabled;
@@ -163,7 +252,65 @@ const updateCh = async () => {
}
};
const testChannel = async () => {
if (!ch.value) return;
testing.value = true;
try {
const result = await channelStore.testChannel(ch.value.id);
setToast(`Connection OK (${result.data?.latency_ms}ms)`, 'success');
} catch (err: any) {
setToast(err.response?.data?.error || 'Connection test failed', 'error');
} finally {
testing.value = false;
}
};
const goBack = () => {
router.push({ name: 'Channels' });
};
// Model binding
const addModelModalRef = ref<HTMLDialogElement | null>(null);
const addingModel = ref(false);
const newBinding = ref({
model_id: 0,
upstream_model: '',
weight: 1,
});
const openAddModelModal = () => {
newBinding.value = { model_id: 0, upstream_model: '', weight: 1 };
addModelModalRef.value?.showModal();
};
const closeAddModelModal = () => {
addModelModalRef.value?.close();
};
const addModelBinding = async () => {
if (!channelId.value || !newBinding.value.model_id || !newBinding.value.upstream_model) return;
addingModel.value = true;
try {
await channelStore.addChannelModel(channelId.value as string, newBinding.value);
setToast('Model binding added', 'success');
closeAddModelModal();
await fetchBindings();
} catch (err: any) {
setToast(err.response?.data?.error || 'Failed to add binding', 'error');
} finally {
addingModel.value = false;
}
};
const confirmDeleteBinding = async (b: ChannelModelBinding) => {
if (confirm(`Remove binding for model "${b.model_name}"?`)) {
try {
await channelStore.deleteChannelModel(channelId.value as string, b.id);
setToast('Binding removed', 'success');
await fetchBindings();
} catch (err: any) {
setToast('Delete failed', 'error');
}
}
};
</script>
+277
View File
@@ -0,0 +1,277 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader />
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm text-base-content/60">Manage model pricing and channel bindings.</p>
<div v-if="summary" class="mt-1 flex gap-3 text-xs text-base-content/50">
<span>Total: {{ summary.total }}</span>
<span v-if="summary.unpriced > 0" class="text-warning">{{ summary.unpriced }} unpriced</span>
<span v-if="summary.missing.length > 0" class="text-error">{{ summary.missing.length }} orphan bindings</span>
</div>
</div>
<div class="flex items-center gap-2">
<button v-if="models.length > 0" class="btn btn-ghost btn-sm" @click="confirmDeleteUnused">
<TrashIcon class="h-4 w-4" aria-hidden="true" />Clean Unused
</button>
<button class="btn btn-primary btn-sm" @click="openCreateModal" aria-label="Create new model">
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Model
</button>
</div>
</div>
<!-- Model cards -->
<div class="space-y-3">
<div v-for="m in models" :key="m.id"
class="card border bg-base-100 shadow-sm"
:class="m.channels && m.channels.length > 0 ? 'border-base-300/60' : 'border-warning/60 bg-warning/5'">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm font-medium text-base-content">{{ m.name }}</span>
<span v-if="m.display_name" class="text-xs text-base-content/50">{{ m.display_name }}</span>
<span v-if="m.channels && m.channels.length > 0" class="badge badge-xs badge-ghost">渠道允许</span>
<span v-else class="badge badge-xs bg-yellow-200 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300">悬空</span>
<span :class="m.enabled ? 'badge badge-xs bg-green-200 text-green-800 dark:bg-green-900/50 dark:text-green-300' : 'badge badge-xs badge-ghost'">{{ m.enabled ? '启用' : '停用' }}</span>
<span v-if="m.denied" class="badge badge-xs badge-error">已禁止</span>
<span v-if="m.needs_pricing" class="badge badge-xs bg-orange-200 text-orange-800 dark:bg-orange-900/50 dark:text-orange-300">未定价</span>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(m)">编辑</button>
<button class="btn btn-ghost btn-xs text-error" @click="confirmDeleteModel(m)">删除</button>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span class="font-mono text-xs text-base-content/60">入 {{ formatPrice(m.input_price) }}</span>
<span class="font-mono text-xs text-base-content/60">出 {{ formatPrice(m.output_price) }}</span>
<span class="font-mono text-xs text-base-content/60">缓存读 {{ formatPrice(m.cache_read_price) }}</span>
</div>
</div>
<div v-if="m.channels && m.channels.length > 0" class="border-t border-base-300/60 px-4 py-2">
<p class="mb-1.5 text-[11px] font-medium text-base-content/50">允许渠道</p>
<div class="flex flex-wrap gap-2">
<span v-for="ch in m.channels" :key="ch.id"
class="inline-flex items-center rounded-md border border-base-300/60 bg-base-100 px-2 py-0.5 font-mono text-[11px] text-base-content/60">
{{ ch.channel_name }} → {{ ch.upstream_model }}
</span>
</div>
</div>
<p v-else class="border-t border-base-300/60 bg-amber-100 px-4 py-2 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-100">
悬空模型:无任何渠道提供,客户端无法调用
</p>
</div>
<!-- Empty state -->
<div v-if="models.length === 0" class="card border border-base-300/60 bg-base-100 px-4 py-14 text-center">
<BoxesIcon class="mx-auto h-10 w-10 text-base-content/20" aria-hidden="true" />
<h2 class="mt-2 text-sm font-semibold">No models yet</h2>
<p class="mt-1 max-w-xs text-sm text-base-content/60">
Add models to manage pricing and channel bindings.
</p>
<button class="btn btn-primary btn-sm mt-3" @click="openCreateModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Model
</button>
</div>
</div>
<!-- Create/Edit modal -->
<dialog ref="modalRef" class="modal">
<div class="modal-box max-w-lg px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<h3 class="mb-4 text-lg font-bold">{{ editingModel ? 'Edit Model' : 'New Model' }}</h3>
<form @submit.prevent="saveModel" class="space-y-4">
<label class="floating-label">
<span>Model Name *</span>
<input v-model="form.name" type="text" placeholder="e.g. gpt-4o" class="input w-full" required
:disabled="!!editingModel" />
</label>
<label class="floating-label">
<span>Display Name</span>
<input v-model="form.display_name" type="text" placeholder="e.g. GPT-4o" class="input w-full" />
</label>
<div class="grid grid-cols-3 gap-3">
<label class="floating-label">
<span>Input $/M tokens</span>
<input v-model.number="form.input_price" type="number" step="0.01" min="0" placeholder="0"
class="input w-full" />
</label>
<label class="floating-label">
<span>Output $/M tokens</span>
<input v-model.number="form.output_price" type="number" step="0.01" min="0" placeholder="0"
class="input w-full" />
</label>
<label class="floating-label">
<span>Cache Read $/M</span>
<input v-model.number="form.cache_read_price" type="number" step="0.01" min="0" placeholder="0"
class="input w-full" />
</label>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="floating-label">
<span>Sort Order</span>
<input v-model.number="form.sort" type="number" min="0" placeholder="0" class="input w-full" />
</label>
<div class="flex items-center gap-2 pt-6">
<input type="checkbox" class="toggle toggle-success toggle-sm" v-model="form.enabled" />
<span class="text-sm">{{ form.enabled ? 'Enabled' : 'Disabled' }}</span>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (editingModel ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useModelStore, type Model, type NewModelPayload } from '@/stores/model';
import { useToast } from '@/composables/toast';
import {
BoxesIcon, PencilIcon, PlusIcon, TrashIcon
} from '@lucide/vue';
const modelStore = useModelStore();
const { setToast } = useToast();
const models = ref<Model[]>([]);
const summary = ref(modelStore.summary);
const editingModel = ref<Model | null>(null);
const saving = ref(false);
const form = reactive<NewModelPayload & { enabled: boolean }>({
name: '',
display_name: '',
input_price: 0,
output_price: 0,
cache_read_price: 0,
sort: 0,
enabled: true,
});
onMounted(async () => {
await fetchModels();
});
const fetchModels = async () => {
await modelStore.fetchModels();
models.value = modelStore.models;
summary.value = modelStore.summary;
};
const formatPrice = (price: number) => {
return price === 0 ? '-' : `$${price.toFixed(2)}`;
};
const toggleEnabled = async (m: Model) => {
try {
await modelStore.updateModel(m.id, { enabled: !m.enabled });
setToast(`Model ${m.name} ${m.enabled ? 'disabled' : 'enabled'}`, 'success');
await fetchModels();
} catch (error: any) {
setToast('Status update failed', 'error');
}
};
const openCreateModal = () => {
editingModel.value = null;
form.name = '';
form.display_name = '';
form.input_price = 0;
form.output_price = 0;
form.cache_read_price = 0;
form.sort = 0;
form.enabled = true;
modalRef.value?.showModal();
};
const openEditModal = (m: Model) => {
editingModel.value = m;
form.name = m.name;
form.display_name = m.display_name || '';
form.input_price = m.input_price;
form.output_price = m.output_price;
form.cache_read_price = m.cache_read_price;
form.sort = m.sort;
form.enabled = m.enabled;
modalRef.value?.showModal();
};
const saveModel = async () => {
saving.value = true;
try {
if (editingModel.value) {
await modelStore.updateModel(editingModel.value.id, {
display_name: form.display_name,
input_price: form.input_price,
output_price: form.output_price,
cache_read_price: form.cache_read_price,
sort: form.sort,
enabled: form.enabled,
});
setToast('Model updated', 'success');
} else {
await modelStore.createModel({
name: form.name,
display_name: form.display_name,
input_price: form.input_price,
output_price: form.output_price,
cache_read_price: form.cache_read_price,
sort: form.sort,
enabled: form.enabled,
});
setToast('Model created', 'success');
}
closeModal();
await fetchModels();
} catch (error: any) {
setToast(error.message || 'Save failed', 'error');
} finally {
saving.value = false;
}
};
const confirmDeleteModel = async (m: Model) => {
if (confirm(`Delete model "${m.name}"? This will also remove all channel bindings.`)) {
try {
await modelStore.deleteModel(m.id);
setToast(`Model ${m.name} deleted`, 'success');
await fetchModels();
} catch (error: any) {
setToast('Delete failed', 'error');
}
}
};
const confirmDeleteUnused = async () => {
if (confirm('Delete all models that are not bound to any channel?')) {
try {
const result = await modelStore.deleteUnusedModels();
const count = result.data?.count || 0;
setToast(`Deleted ${count} unused models`, 'success');
await fetchModels();
} catch (error: any) {
setToast('Cleanup failed', 'error');
}
}
};
const modalRef = ref<HTMLDialogElement | null>(null);
const closeModal = () => {
modalRef.value?.close();
};
</script>
+225
View File
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Model, ModelSummary } from '@/types'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
const { setToast } = useToast()
const models = ref<Model[]>([])
const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
const editOpen = ref(false)
const editing = ref<Model | null>(null)
const saving = ref(false)
const quickName = ref('')
const clearing = ref(false)
const unused = computed(() => models.value.filter((m) => m.channels.length === 0))
async function clearUnused() {
if (!unused.value.length) {
setToast('没有未绑定渠道的模型', 'info')
return
}
const names = unused.value.map((m) => m.name)
if (!confirm(`确定删除 ${names.length} 个未绑定渠道的模型?\n\n${names.join('\n')}`)) return
clearing.value = true
try {
const { data } = await request.delete('/admin/models/unused')
setToast(`已清除 ${data.data.count} 个模型`, 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
clearing.value = false
}
}
function quickAdd() {
openCreate()
if (quickName.value) form.name = quickName.value.trim()
}
const form = reactive({
name: '',
input_price: 0,
output_price: 0,
cache_read_price: 0,
enabled: true,
})
async function load() {
try {
const { data } = await request.get('/admin/models')
models.value = data.data
summary.value = data.summary
} catch (e) {
setToast(errMsg(e), 'error')
}
}
function openCreate() {
editing.value = null
Object.assign(form, { name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
editOpen.value = true
}
function openEdit(m: Model) {
editing.value = m
Object.assign(form, {
name: m.name,
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
enabled: m.enabled,
})
editOpen.value = true
}
async function save() {
saving.value = true
const payload = {
input_price: Number(form.input_price),
output_price: Number(form.output_price),
cache_read_price: Number(form.cache_read_price),
enabled: form.enabled,
}
try {
if (editing.value) {
await request.put(`/admin/models/${editing.value.id}`, payload)
setToast('模型已更新', 'success')
} else {
await request.post('/admin/models', { name: form.name, ...payload })
setToast('模型已创建', 'success')
}
editOpen.value = false
await load()
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
async function removeModel(m: Model) {
if (!confirm(`删除模型 ${m.name}?`)) return
try {
await request.delete(`/admin/models/${m.id}`)
setToast('模型已删除', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">模型与定价</h1>
<p class="text-sm text-base-content/60">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
</div>
<div class="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
<input
v-model="quickName"
placeholder="模型名,如 glm-4.7-flash"
class="h-10 min-w-0 flex-1 rounded-md border border-base-300/60 bg-base-100 px-3 font-mono text-xs outline-none focus:border-primary sm:w-52 sm:flex-none"
@keyup.enter="quickAdd"
/>
<Button class="shrink-0" @click="quickAdd">添加模型</Button>
<Button
size="md"
variant="danger"
class="shrink-0 px-1!"
:loading="clearing"
:disabled="!unused.length"
@click="clearUnused"
>
清除悬空{{ unused.length ? `(${unused.length})` : '' }}
</Button>
</div>
</div>
<!-- 提示:定价目录 = 渠道选中的模型 + 手动添加的模型 -->
<div v-if="summary.missing.length" class="card border border-error/50 bg-error/5 p-4">
<p class="text-sm font-medium text-error">以下渠道选中的模型不在定价目录</p>
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-base-content/60">
{{ x.channel }} → {{ x.upstream_model || '模型 #' + x.model_id }}(请到渠道抽屉重新选中,或手动添加)
</p>
</div>
<p v-else-if="summary.unpriced > 0" class="text-xs text-base-content/60">
有 <span class="font-mono text-warning">{{ summary.unpriced }}</span> 个渠道允许的模型未定价,网关将按示例价计费
</p>
<p v-else class="text-xs text-base-content/60">定价目录中渠道允许的模型均已定价</p>
<div class="space-y-3">
<div v-for="m in models" :key="m.id" :class="m.channels.length ? 'card border border-base-300/60 bg-base-100' : 'card border border-warning/60 bg-warning/5'">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-base-content">{{ m.name }}</span>
<Badge v-if="m.channels.length" variant="neutral">渠道允许</Badge>
<Badge v-else variant="warn">悬空</Badge>
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
<Badge v-if="m.denied" variant="err">已禁止</Badge>
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
</div>
<div class="flex gap-2">
<button class="text-xs text-base-content/60 hover:text-base-content" @click="openEdit(m)">编辑</button>
<button class="text-xs text-base-content/60 hover:text-error" @click="removeModel(m)">删除</button>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span class="font-mono text-xs text-base-content/60">入 {{ m.input_price }}</span>
<span class="font-mono text-xs text-base-content/60">出 {{ m.output_price }}</span>
<span class="font-mono text-xs text-base-content/60">缓存读 {{ m.cache_read_price }}</span>
</div>
</div>
<div v-if="m.channels.length" class="border-t border-base-300/60 px-4 py-2">
<p class="mb-1.5 text-[11px] font-medium text-base-content/50">允许渠道(渠道抽屉中管理)</p>
<div class="flex flex-wrap gap-2">
<span
v-for="b in m.channels"
:key="b.id"
class="inline-flex items-center rounded-md border border-base-300/60 bg-base-100 px-2 py-0.5 font-mono text-[11px] text-base-content/60"
>
{{ b.channel_name }} → {{ b.upstream_model }}
</span>
</div>
</div>
<p v-else class="border-t border-base-300/60 px-4 py-2 text-xs text-warning">
悬空模型:无任何渠道提供,客户端无法调用
</p>
</div>
<p v-if="models.length === 0" class="card border border-base-300/60 bg-base-100 px-4 py-10 text-center text-sm text-base-content/60">
还没有模型,点击「添加模型」或到渠道页「导入模型」
</p>
</div>
<!-- 模型编辑 -->
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
const { setToast } = useToast()
const loading = ref(false)
const saving = ref(false)
const registrationEnabled = ref(true)
const passwordLoginEnabled = ref(true)
async function load() {
loading.value = true
try {
const [regRes, pwdRes] = await Promise.all([
request.get('/admin/config/registration'),
request.get('/admin/config/password-login'),
])
registrationEnabled.value = regRes.data.data.enabled
passwordLoginEnabled.value = pwdRes.data.data.enabled
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
loading.value = false
}
}
async function saveRegistration(enabled: boolean) {
saving.value = true
try {
await request.put('/admin/config/registration', { enabled })
registrationEnabled.value = enabled
setToast('注册设置已更新', 'success')
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
async function savePasswordLogin(enabled: boolean) {
saving.value = true
try {
await request.put('/admin/config/password-login', { enabled })
passwordLoginEnabled.value = enabled
setToast('密码登录设置已更新', 'success')
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-lg font-semibold">系统配置</h1>
<p class="text-sm text-base-content/60">管理平台全局设置</p>
</div>
<div v-if="loading" class="py-10 text-center text-sm text-base-content/50">加载中…</div>
<template v-else>
<!-- 开放注册 -->
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">开放注册</h3>
<p class="mt-1 text-xs text-base-content/50">允许新用户通过注册页面创建账号</p>
</div>
<button
type="button"
role="switch"
:aria-checked="registrationEnabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="registrationEnabled ? 'bg-primary' : 'bg-base-200'"
:disabled="saving"
@click="saveRegistration(!registrationEnabled)"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="registrationEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
<!-- 密码登录 -->
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">密码登录</h3>
<p class="mt-1 text-xs text-base-content/50">允许用户通过用户名和密码登录</p>
</div>
<button
type="button"
role="switch"
:aria-checked="passwordLoginEnabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="passwordLoginEnabled ? 'bg-primary' : 'bg-base-200'"
:disabled="saving"
@click="savePasswordLogin(!passwordLoginEnabled)"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="passwordLoginEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</template>
</div>
</template>
+1 -1
View File
@@ -9,7 +9,7 @@ import path from 'path'
// 需要自签名 HTTPS 时设置 VITE_DEV_HTTPS=true
const useHttps = process.env.VITE_DEV_HTTPS === 'true'
// 后端地址:默认 make dev-backend 启动的 8080,可用 VITE_DEV_API_TARGET 覆盖
const apiTarget = process.env.VITE_DEV_API_TARGET || 'http://localhost:8080'
const apiTarget = process.env.VITE_DEV_API_TARGET || 'http://localhost:3000'
// https://vite.dev/config/
export default defineConfig({