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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user