feat(web): management console frontend

Vue 3 + TS + Vite + Tailwind with self-built UI components. Landing,
auth, user dashboard (keys/usage/settings), and admin console
(overview/models/channels/users/usage/config). Theme system
(light/dark/system) with pre-paint boot script, theme-aware ECharts,
and a channel editor with multi-format (Anthropic/OpenAI) selection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent d0e31b198f
commit 489a79564c
51 changed files with 6432 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenTeam · LLM API Relay</title>
<script>
;(function () {
// Resolve theme before first paint to avoid a flash of the wrong theme.
try {
var m = localStorage.getItem('ot_theme')
if (m !== 'light' && m !== 'dark' && m !== 'system') m = 'system'
var light =
m === 'light' ||
(m === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches)
document.documentElement.setAttribute('data-theme', light ? 'light' : 'dark')
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark')
}
})()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2641
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "openteam-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"echarts": "^5.5.1",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "~5.6.3",
"vite": "^5.4.11",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import ToastHost from '@/components/ui/ToastHost.vue'
</script>
<template>
<RouterView />
<ToastHost />
</template>
+127
View File
@@ -0,0 +1,127 @@
// Minimal fetch client for the management API (/api/v1).
// Success envelope is { code: 0, data }; errors are { code, message }.
// On 401 with a stored access token we attempt one refresh (HttpOnly cookie)
// then retry the request once.
const TOKEN_KEY = 'ot_access_token'
let accessToken: string = localStorage.getItem(TOKEN_KEY) || ''
let refreshing: Promise<string | null> | null = null
export function getToken(): string {
return accessToken
}
export function setToken(token: string): void {
accessToken = token
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
interface Envelope<T> {
code: number
message?: string
data?: T
}
async function parse<T>(res: Response): Promise<T> {
const text = await res.text()
let env: Envelope<T> | null = null
try {
env = JSON.parse(text) as Envelope<T>
} catch {
env = null
}
if (!res.ok || !env || env.code !== 0) {
throw new ApiError(env?.message || `request failed (${res.status})`, res.status)
}
return env.data as T
}
export interface RequestOptions {
body?: unknown
auth?: boolean // default true: attach Bearer token
retryOn401?: boolean // default true
}
export async function api<T>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const auth = opts.auth !== false
if (auth && accessToken) headers.Authorization = `Bearer ${accessToken}`
const doFetch = (): Promise<T> =>
fetch(path, {
method,
headers,
credentials: 'include', // send/accept the refresh cookie
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
}).then((res) => parse<T>(res))
try {
return await doFetch()
} catch (err) {
if (
err instanceof ApiError &&
err.status === 401 &&
auth &&
opts.retryOn401 !== false &&
accessToken
) {
const fresh = await tryRefresh()
if (fresh) {
setToken(fresh)
headers.Authorization = `Bearer ${fresh}`
return doFetch()
}
}
throw err
}
}
// tryRefresh exchanges the HttpOnly refresh cookie for a new access token.
// Returns the new access token, or null when there is no valid session.
export async function tryRefresh(): Promise<string | null> {
if (refreshing) return refreshing
refreshing = (async () => {
try {
const data = await api<{ token: { accessToken: string } }>('POST', '/api/v1/auth/refresh', {
auth: false,
retryOn401: false,
})
return data.token?.accessToken ?? null
} catch {
return null
} finally {
refreshing = null
}
})()
return refreshing
}
export async function get<T>(path: string): Promise<T> {
return api<T>('GET', path)
}
export async function post<T>(path: string, body?: unknown): Promise<T> {
return api<T>('POST', path, { body })
}
export async function put<T>(path: string, body?: unknown): Promise<T> {
return api<T>('PUT', path, { body })
}
export async function patch<T>(path: string, body?: unknown): Promise<T> {
return api<T>('PATCH', path, { body })
}
export async function del<T>(path: string): Promise<T> {
return api<T>('DELETE', path)
}
+133
View File
@@ -0,0 +1,133 @@
// Typed endpoints for the management API.
import * as c from './client'
import type {
AdminOverview,
ApiKey,
ApiKeyCreated,
Channel,
ChannelBinding,
LoginResult,
Model,
Page,
RechargeOrder,
UsageLog,
UsageStatRow,
UsageSummary,
User,
} from '@/types'
// ---- auth / user ----
export const authApi = {
login: (account: string, password: string) =>
c.post<LoginResult>('/api/v1/auth/login', { account, password }),
register: (data: { username: string; email: string; password: string; inviteCode?: string }) =>
c.post<LoginResult>('/api/v1/auth/register', data),
logout: () => c.post<{ ok: boolean }>('/api/v1/auth/logout'),
me: () => c.get<User>('/api/v1/auth/me'),
profile: () => c.get<User>('/api/v1/user/profile'),
balance: () => c.get<{ balance: string }>('/api/v1/user/balance'),
}
// ---- API keys ----
export interface KeyCreateInput {
name: string
allowedModels?: string[]
expiresAt?: string
quotaRequestsPerDay?: number
quotaTokensPerDay?: number
}
export const keysApi = {
list: () => c.get<ApiKey[]>('/api/v1/keys'),
create: (data: KeyCreateInput) => c.post<ApiKeyCreated>('/api/v1/keys', data),
update: (id: number, data: Partial<KeyCreateInput> & { status?: string }) =>
c.patch<ApiKey>(`/api/v1/keys/${id}`, data),
remove: (id: number) => c.del<{ ok: boolean }>(`/api/v1/keys/${id}`),
}
// ---- usage (user) ----
export const usageApi = {
summary: () => c.get<UsageSummary>('/api/v1/usage/summary'),
stats: (params: { from?: string; to?: string; group?: 'day' | 'model' }) => {
const q = new URLSearchParams()
if (params.from) q.set('from', params.from)
if (params.to) q.set('to', params.to)
q.set('group', params.group || 'day')
return c.get<UsageStatRow[]>(`/api/v1/usage/stats?${q}`)
},
logs: (params: { page?: number; from?: string; to?: string; model?: string; keyId?: string }) => {
const q = new URLSearchParams()
q.set('page', String(params.page || 1))
if (params.from) q.set('from', params.from)
if (params.to) q.set('to', params.to)
if (params.model) q.set('model', params.model)
if (params.keyId) q.set('keyId', params.keyId)
return c.get<Page<UsageLog>>(`/api/v1/usage/logs?${q}`)
},
}
// ---- recharges (reserved) ----
export const rechargesApi = {
list: () => c.get<RechargeOrder[]>('/api/v1/recharges'),
create: (amount: string) => c.post<RechargeOrder>('/api/v1/recharges', { amount }),
}
// ---- admin ----
export const adminApi = {
overview: () => c.get<AdminOverview>('/api/v1/admin/stats/overview'),
channels: {
list: () => c.get<Channel[]>('/api/v1/admin/channels'),
create: (data: Partial<Channel> & { apiKey: string }) =>
c.post<Channel>('/api/v1/admin/channels', data),
update: (id: number, data: Partial<Channel>) => c.put<Channel>(`/api/v1/admin/channels/${id}`, data),
remove: (id: number) => c.del<{ ok: boolean }>(`/api/v1/admin/channels/${id}`),
test: (id: number) =>
c.post<{ ok: boolean; latencyMs: number; model: string }>(`/api/v1/admin/channels/${id}/test`),
importModels: (id: number) =>
c.post<{ imported: string[]; count: number }>(`/api/v1/admin/channels/${id}/import-models`),
bindings: (id: number) => c.get<ChannelBinding[]>(`/api/v1/admin/channels/${id}/bindings`),
saveBindings: (id: number, items: { modelId: number; upstreamModel: string; weight: number }[]) =>
c.put<{ ok: boolean }>(`/api/v1/admin/channels/${id}/bindings`, { items }),
},
models: {
list: () => c.get<Model[]>('/api/v1/admin/models'),
create: (data: Partial<Model>) => c.post<Model>('/api/v1/admin/models', data),
update: (id: number, data: Partial<Model>) => c.put<Model>(`/api/v1/admin/models/${id}`, data),
price: (id: number, p: { inputPrice: string; outputPrice: string; cacheReadPrice?: string }) =>
c.put<{ ok: boolean }>(`/api/v1/admin/models/${id}/price`, p),
},
users: {
list: (page = 1) => c.get<Page<User>>(`/api/v1/admin/users?page=${page}`),
update: (id: number, data: { role?: string; status?: string }) =>
c.patch<User>(`/api/v1/admin/users/${id}`, data),
adjustBalance: (id: number, amount: string, remark?: string) =>
c.post<{ balanceAfter: string }>(`/api/v1/admin/users/${id}/balance`, { amount, remark }),
},
usage: {
overview: () => c.get<AdminOverview>('/api/v1/admin/stats/overview'),
rows: (params: { from?: string; to?: string; group?: 'day' | 'model' | 'user'; userId?: string; model?: string }) => {
const q = new URLSearchParams()
if (params.from) q.set('from', params.from)
if (params.to) q.set('to', params.to)
q.set('group', params.group || 'day')
if (params.userId) q.set('userId', params.userId)
if (params.model) q.set('model', params.model)
return c.get<UsageStatRow[]>(`/api/v1/admin/usage?${q}`)
},
},
config: {
get: () => c.get<Record<string, unknown>>('/api/v1/admin/config'),
put: (kv: Record<string, unknown>) => c.put<{ ok: boolean }>('/api/v1/admin/config', kv),
},
recharges: {
list: () => c.get<RechargeOrder[]>('/api/v1/admin/recharges'),
approve: (id: number) => c.post<{ ok: boolean; status: string }>(`/api/v1/admin/recharges/${id}/approve`),
reject: (id: number) => c.post<{ ok: boolean; status: string }>(`/api/v1/admin/recharges/${id}/reject`),
},
}
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import Nav, { type NavItem } from '@/components/ui/Nav.vue'
import ThemeSwitch from '@/components/ui/ThemeSwitch.vue'
import { useAuth } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
import { toast } from '@/components/ui/toast'
const { state, logout } = useAuth()
const router = useRouter()
const mobileOpen = ref(false)
const adminNav: NavItem[] = [
{ label: '总览', to: '/admin', icon: 'overview' },
{ label: '渠道管理', to: '/admin/channels', icon: 'channels' },
{ label: '模型定价', to: '/admin/models', icon: 'models' },
{ label: '用户管理', to: '/admin/users', icon: 'users' },
{ label: '用量统计', to: '/admin/usage', icon: 'usage' },
{ label: '系统配置', to: '/admin/config', icon: 'config' },
{ label: '返回控制台', to: '/dashboard', icon: 'dashboard', section: '用户' },
]
async function onLogout() {
await logout()
toast.info('已退出登录')
router.push('/login')
}
</script>
<template>
<div class="min-h-screen bg-bg">
<Transition name="fade">
<div v-if="mobileOpen" class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="mobileOpen = false" />
</Transition>
<aside
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-line bg-surface transition-transform lg:translate-x-0"
:class="mobileOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex h-14 items-center gap-2 border-b border-line px-4">
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-brand text-white">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7.5L16.5 3H5zm3 4h8v2H8V7zm0 4h8v2H8v-2zm0 4h5v2H8v-2z" />
</svg>
</div>
<div>
<p class="font-mono text-sm font-bold leading-tight text-ink">openteam</p>
<p class="text-[10px] font-medium uppercase tracking-wider text-brand">Admin</p>
</div>
</div>
<div class="flex-1 overflow-y-auto px-3 py-4">
<Nav :items="adminNav" />
</div>
<div class="border-t border-line px-4 py-3">
<ThemeSwitch class="mb-2 w-full" />
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm text-ink">{{ state.user?.username }}</p>
<Button variant="ghost" size="sm" @click="onLogout">退出</Button>
</div>
</div>
</aside>
<div class="lg:pl-60">
<header class="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-line bg-bg/80 px-4 backdrop-blur lg:hidden">
<button class="rounded p-1 text-ink-soft hover:bg-surface-2" aria-label="打开菜单" @click="mobileOpen = true">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="font-mono text-sm font-bold text-ink">管理后台</span>
</header>
<main class="mx-auto w-full max-w-6xl px-4 py-6 lg:px-8">
<RouterView />
</main>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Nav, { type NavItem } from '@/components/ui/Nav.vue'
import ThemeSwitch from '@/components/ui/ThemeSwitch.vue'
import { useAuth } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
import { toast } from '@/components/ui/toast'
const { state, logout } = useAuth()
const router = useRouter()
const mobileOpen = ref(false)
const userNav = computed<NavItem[]>(() => {
const items: NavItem[] = [
{ label: '仪表盘', to: '/dashboard', icon: 'dashboard' },
{ label: 'API 密钥', to: '/keys', icon: 'keys' },
{ label: '用量明细', to: '/usage', icon: 'usage' },
{ label: '账户设置', to: '/settings', icon: 'settings' },
]
if (state.user?.role === 'admin') {
items.push({ label: '管理后台', to: '/admin', icon: 'admin', section: '管理' })
}
return items
})
const balance = computed(() => state.user?.balance ?? '—')
const username = computed(() => state.user?.username ?? '')
async function onLogout() {
await logout()
toast.info('已退出登录')
router.push('/login')
}
</script>
<template>
<div class="min-h-screen bg-bg">
<!-- mobile backdrop -->
<Transition name="fade">
<div v-if="mobileOpen" class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="mobileOpen = false" />
</Transition>
<!-- sidebar -->
<aside
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-line bg-surface transition-transform lg:translate-x-0"
:class="mobileOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex h-14 items-center gap-2 border-b border-line px-4">
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-brand text-white">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
</div>
<span class="font-mono text-sm font-bold tracking-tight text-ink">openteam</span>
</div>
<div class="flex-1 overflow-y-auto px-3 py-4">
<Nav :items="userNav" />
</div>
<div class="border-t border-line px-4 py-3">
<ThemeSwitch class="mb-2 w-full" />
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-ink">{{ username }}</p>
<p class="mono text-xs text-ink-mute">余额 {{ balance }}</p>
</div>
<Button variant="ghost" size="sm" @click="onLogout">退出</Button>
</div>
</div>
</aside>
<!-- main -->
<div class="lg:pl-60">
<header class="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-line bg-bg/80 px-4 backdrop-blur lg:hidden">
<button class="rounded p-1 text-ink-soft hover:bg-surface-2" aria-label="打开菜单" @click="mobileOpen = true">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="font-mono text-sm font-bold text-ink">openteam</span>
</header>
<main class="mx-auto w-full max-w-6xl px-4 py-6 lg:px-8">
<RouterView />
</main>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
type Tone = 'ok' | 'warn' | 'danger' | 'info' | 'neutral'
const props = withDefaults(defineProps<{ tone?: Tone }>(), { tone: 'neutral' })
const cls = computed(() => {
const tones: Record<Tone, string> = {
ok: 'bg-ok/10 text-ok',
warn: 'bg-warn/10 text-warn',
danger: 'bg-danger/10 text-danger',
info: 'bg-info/10 text-info',
neutral: 'bg-surface-2 text-ink-soft',
}
return `inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${tones[props.tone]}`
})
</script>
<template>
<span :class="cls"><slot /></span>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import Spinner from './Spinner.vue'
type Variant = 'primary' | 'ghost' | 'outline' | 'danger'
type Size = 'xs' | 'sm' | 'md'
const props = withDefaults(
defineProps<{
variant?: Variant
size?: Size
loading?: boolean
disabled?: boolean
type?: 'button' | 'submit'
block?: boolean
}>(),
{ variant: 'primary', size: 'md', loading: false, disabled: false, type: 'button', block: false },
)
defineEmits<{ (e: 'click', ev: MouseEvent): void }>()
const cls = computed(() => {
const base = 'inline-flex items-center justify-center gap-1.5 rounded font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 disabled:cursor-not-allowed disabled:opacity-50'
const sizes: Record<Size, string> = {
xs: 'h-6 px-2 text-xs',
sm: 'h-8 px-3 text-xs',
md: 'h-9 px-4 text-sm',
}
const variants: Record<Variant, string> = {
primary: 'bg-brand text-white hover:bg-brand-hover',
ghost: 'text-ink-soft hover:bg-surface-2 hover:text-ink',
outline: 'border border-line-strong text-ink-soft hover:border-brand hover:text-ink',
danger: 'bg-danger/10 text-danger hover:bg-danger/20',
}
return [base, sizes[props.size], variants[props.variant], props.block ? 'w-full' : ''].join(' ')
})
</script>
<template>
<button :type="type" :class="cls" :disabled="disabled || loading" @click="(ev) => $emit('click', ev)">
<Spinner v-if="loading" size="sm" />
<slot />
</button>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string
desc?: string
padded?: boolean
}>(),
{ title: '', desc: '', padded: true },
)
</script>
<template>
<section class="rounded-lg border border-line bg-surface shadow-card">
<header v-if="title || desc" class="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-3">
<div>
<h3 class="text-sm font-semibold text-ink">{{ title }}</h3>
<p v-if="desc" class="mt-0.5 text-xs text-ink-mute">{{ desc }}</p>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</header>
<div :class="padded ? 'p-4' : ''">
<slot />
</div>
</section>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
withDefaults(defineProps<{ title?: string; desc?: string }>(), { title: '', desc: '' })
</script>
<template>
<div class="flex flex-col items-center justify-center gap-2 py-12 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-surface-2 text-ink-mute">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 9h18" />
<path d="M8 14h3" />
</svg>
</div>
<h4 v-if="title" class="text-sm font-medium text-ink-soft">{{ title }}</h4>
<p v-if="desc" class="max-w-sm text-xs text-ink-mute">{{ desc }}</p>
<div v-if="$slots.actions" class="mt-2">
<slot name="actions" />
</div>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
type?: string
placeholder?: string
error?: string
hint?: string
disabled?: boolean
mono?: boolean
autocomplete?: string
}>(),
{ type: 'text', placeholder: '', error: '', hint: '', disabled: false, mono: false, autocomplete: 'off' },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const cls = computed(() => {
const base =
'w-full rounded border bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-mute focus:outline-none focus:ring-2 focus:ring-brand/40 transition-colors disabled:opacity-60'
const border = props.error
? 'border-danger/60 focus:border-danger'
: 'border-line focus:border-brand'
const font = props.mono ? 'font-mono' : ''
return [base, border, font].join(' ')
})
</script>
<template>
<label class="block">
<span v-if="label" class="mb-1.5 block text-xs font-medium text-ink-soft">{{ label }}</span>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:autocomplete="autocomplete"
:class="cls"
spellcheck="false"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<span v-if="error" class="mt-1 block text-xs text-danger">{{ error }}</span>
<span v-else-if="hint" class="mt-1 block text-xs text-ink-mute">{{ hint }}</span>
</label>
</template>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
const props = withDefaults(
defineProps<{
open: boolean
title?: string
width?: string
}>(),
{ title: '', width: 'max-w-lg' },
)
const emit = defineEmits<{ (e: 'close'): void }>()
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape' && props.open) emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="emit('close')" />
<div class="relative rounded-lg border border-line-strong bg-surface shadow-pop" :class="width">
<header class="flex items-center justify-between border-b border-line px-4 py-3">
<h3 class="text-sm font-semibold text-ink">{{ title }}</h3>
<button class="rounded p-1 text-ink-mute transition-colors hover:bg-surface-2 hover:text-ink" aria-label="关闭" @click="emit('close')">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</header>
<div class="max-h-[70vh] overflow-y-auto p-4">
<slot />
</div>
<footer v-if="$slots.footer" class="flex justify-end gap-2 border-t border-line px-4 py-3">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
export interface NavItem {
label: string
to: string
icon?: string
section?: string
}
defineProps<{ items: NavItem[] }>()
const icons: Record<string, string> = {
dashboard: 'M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6zM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6zM3.75 13.5A2.25 2.25 0 0 1 6 11.25h2.25a2.25 2.25 0 0 1 2.25 2.25v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25v-2.25zM13.5 13.5a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25v2.25A2.25 2.25 0 0 1 18 18h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25z',
keys: 'M15.75 5.25a3 3 0 0 1 3 3m0 0v0a3 3 0 0 1-.879 2.121L15.75 12.5l.75.75a1.5 1.5 0 0 1 0 2.121l-.879.879a1.5 1.5 0 0 1-2.121 0l-.75-.75-4.5 4.5H5.25v-3.75l5.25-5.25A3 3 0 0 1 12 6.75c1.5 0 2.25 0 2.25 0A3 3 0 0 1 15.75 5.25z',
usage: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125z',
settings: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a7.72 7.72 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a7.715 7.715 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z',
admin: 'M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25z',
channels: 'M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25z',
models: 'M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25',
users: 'M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0z',
config: 'M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527a1.125 1.125 0 0 1-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15a1.125 1.125 0 0 1-.94-1.109v-1.094c0-.55.397-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.781-.929l.149-.894z',
overview: 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25',
}
function iconFor(name?: string): string {
return (name && icons[name]) || icons.dashboard
}
</script>
<template>
<nav class="flex flex-col gap-0.5">
<template v-for="item in items" :key="item.to + item.label">
<p v-if="item.section" class="mt-4 px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-ink-mute first:mt-0">
{{ item.section }}
</p>
<RouterLink
:to="item.to"
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-sm text-ink-soft transition-colors hover:bg-surface-2 hover:text-ink"
active-class="bg-brand/10 !text-brand"
>
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path :d="iconFor(item.icon)" />
</svg>
{{ item.label }}
</RouterLink>
</template>
</nav>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
withDefaults(defineProps<{ title: string; desc?: string }>(), { desc: '' })
</script>
<template>
<div class="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-ink">{{ title }}</h2>
<p v-if="desc" class="mt-1 text-sm text-ink-mute">{{ desc }}</p>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
export interface SelectOption {
label: string
value: string
}
withDefaults(
defineProps<{
modelValue: string
label?: string
options: SelectOption[]
disabled?: boolean
}>(),
{ label: '', disabled: false },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const cls =
'w-full appearance-none rounded border border-line bg-bg px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-brand/40 disabled:opacity-60'
</script>
<template>
<label class="block">
<span v-if="label" class="mb-1.5 block text-xs font-medium text-ink-soft">{{ label }}</span>
<div class="relative">
<select :value="modelValue" :disabled="disabled" :class="cls" @change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)">
<option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-mute" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.17l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06z" clip-rule="evenodd" />
</svg>
</div>
</label>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
withDefaults(
defineProps<{ size?: 'sm' | 'md'; label?: string }>(),
{ size: 'md', label: '' },
)
</script>
<template>
<span class="inline-flex items-center gap-2 text-ink-soft">
<svg
class="animate-spin"
:class="size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
</svg>
<span v-if="label" class="text-xs">{{ label }}</span>
</span>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
type Tone = 'ok' | 'warn' | 'danger' | 'info' | 'neutral'
const props = withDefaults(
defineProps<{
label: string
value: string
tone?: Tone
hint?: string
mono?: boolean
}>(),
{ tone: 'neutral', hint: '', mono: false },
)
const toneCls = computed(() => {
const map: Record<Tone, string> = {
ok: 'text-ok',
warn: 'text-warn',
danger: 'text-danger',
info: 'text-info',
neutral: 'text-ink',
}
return map[props.tone]
})
</script>
<template>
<div class="rounded-lg border border-line bg-surface p-4">
<p class="text-xs text-ink-mute">{{ label }}</p>
<p class="mt-1.5 text-xl font-semibold leading-none" :class="[toneCls, mono ? 'font-mono' : '']">
{{ value }}
</p>
<p v-if="hint" class="mt-1.5 text-xs text-ink-mute">{{ hint }}</p>
</div>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts" generic="TRow extends Record<string, unknown>">
import Spinner from './Spinner.vue'
export interface TableColumn {
key: string
label: string
align?: 'left' | 'right' | 'center'
width?: string
}
withDefaults(
defineProps<{
columns: TableColumn[]
rows: TRow[]
loading?: boolean
emptyText?: string
rowKey?: string
}>(),
{ loading: false, emptyText: '暂无数据', rowKey: 'id' },
)
</script>
<template>
<div class="w-full overflow-x-auto">
<table class="table-dense w-full min-w-max text-sm">
<thead>
<tr>
<th
v-for="c in columns"
:key="c.key"
:class="[
c.align === 'right' ? 'text-right' : c.align === 'center' ? 'text-center' : 'text-left',
]"
:style="c.width ? { width: c.width } : undefined"
>
{{ c.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in rows" :key="String((row as TRow)[rowKey] ?? ri)">
<td
v-for="c in columns"
:key="c.key"
:class="[
c.align === 'right' ? 'text-right' : c.align === 'center' ? 'text-center' : 'text-left',
]"
>
<slot :name="`col-${c.key}`" :row="row" :value="(row as TRow)[c.key]">
<span class="text-ink-soft">{{ (row as TRow)[c.key] ?? '—' }}</span>
</slot>
</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="flex justify-center py-10">
<Spinner label="加载中…" />
</div>
<div v-else-if="!rows.length" class="flex justify-center py-10 text-sm text-ink-mute">
{{ emptyText }}
</div>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { themeMode, setThemeMode, type ThemeMode } from '@/stores/theme'
const modes: { key: ThemeMode; title: string; icon: string }[] = [
{ key: 'light', title: '浅色', icon: 'sun' },
{ key: 'dark', title: '深色', icon: 'moon' },
{ key: 'system', title: '跟随系统', icon: 'monitor' },
]
const icons: Record<string, string> = {
sun: 'M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z',
moon: 'M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z',
monitor:
'M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25',
}
</script>
<template>
<div class="flex items-center gap-0.5 rounded-lg border border-line bg-bg p-0.5" role="group" aria-label="主题切换">
<button
v-for="m in modes"
:key="m.key"
type="button"
:title="m.title"
class="flex h-6 min-w-7 flex-1 items-center justify-center rounded-md text-ink-mute transition-colors hover:text-ink"
:class="themeMode === m.key ? 'bg-surface-2 text-brand shadow-sm' : ''"
:aria-pressed="themeMode === m.key"
@click="setThemeMode(m.key)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path :d="icons[m.icon]" />
</svg>
</button>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { toasts } from './toast'
const toneDot: Record<string, string> = {
ok: 'bg-ok',
warn: 'bg-warn',
danger: 'bg-danger',
info: 'bg-info',
}
</script>
<template>
<Teleport to="body">
<div class="pointer-events-none fixed right-4 top-4 z-[100] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
class="pointer-events-auto flex items-start gap-2 rounded-md border border-line-strong bg-surface px-3 py-2.5 shadow-pop"
>
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full" :class="toneDot[t.tone]" />
<span class="text-sm text-ink">{{ t.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.18s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(12px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(12px);
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { ref } from 'vue'
export type ToastTone = 'ok' | 'warn' | 'danger' | 'info'
export interface ToastItem {
id: number
message: string
tone: ToastTone
}
let seed = 0
export const toasts = ref<ToastItem[]>([])
function push(message: string, tone: ToastTone = 'info', duration = 3200) {
const id = ++seed
toasts.value.push({ id, message, tone })
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, duration)
}
export function toast(message: string, tone: ToastTone = 'info') {
push(message, tone)
}
toast.success = (m: string) => push(m, 'ok')
toast.error = (m: string) => push(m, 'danger')
toast.warn = (m: string) => push(m, 'warn')
toast.info = (m: string) => push(m, 'info')
+22
View File
@@ -0,0 +1,22 @@
import type { ApiFormat } from '@/types'
export interface ApiFormatOption {
value: ApiFormat
label: string
desc: string
}
// Anthropic-first ordering to match the API-format display naming.
export const API_FORMATS: ApiFormatOption[] = [
{ value: 'anthropic', label: 'Anthropic Messages', desc: '/v1/messages' },
{ value: 'openai-chat', label: 'OpenAI Chat Completions', desc: '/v1/chat/completions' },
{ value: 'openai-responses', label: 'OpenAI Responses API', desc: '/v1/responses' },
]
export const API_FORMAT_LABELS: Record<ApiFormat, string> = Object.fromEntries(
API_FORMATS.map((f) => [f.value, f.label]),
) as Record<ApiFormat, string>
export const API_FORMAT_DESCS: Record<ApiFormat, string> = Object.fromEntries(
API_FORMATS.map((f) => [f.value, f.desc]),
) as Record<ApiFormat, string>
+20
View File
@@ -0,0 +1,20 @@
// Register the ECharts modules used across the app (tree-shaken build).
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
BarChart,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
])
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
import '@/lib/echarts'
import './styles/tokens.css'
import './styles/base.css'
const app = createApp(App)
app.use(router)
app.mount('#app')
+53
View File
@@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuth } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'landing', component: () => import('@/views/Landing.vue'), meta: { public: true } },
{ path: '/login', name: 'login', component: () => import('@/views/Login.vue'), meta: { public: true, guestOnly: true } },
{ path: '/register', name: 'register', component: () => import('@/views/Register.vue'), meta: { public: true, guestOnly: true } },
{
path: '/',
component: () => import('@/components/layout/AppShell.vue'),
meta: { auth: true },
children: [
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/user/Dashboard.vue') },
{ path: 'keys', name: 'keys', component: () => import('@/views/user/Keys.vue') },
{ path: 'usage', name: 'usage', component: () => import('@/views/user/Usage.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/user/Settings.vue') },
],
},
{
path: '/admin',
component: () => import('@/components/layout/AdminShell.vue'),
meta: { auth: true, admin: true },
children: [
{ path: '', name: 'admin-overview', component: () => import('@/views/admin/Overview.vue') },
{ path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/Channels.vue') },
{ path: 'models', name: 'admin-models', component: () => import('@/views/admin/Models.vue') },
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/Users.vue') },
{ path: 'usage', name: 'admin-usage', component: () => import('@/views/admin/Usage.vue') },
{ path: 'config', name: 'admin-config', component: () => import('@/views/admin/Config.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async (to) => {
const auth = useAuth()
if (!auth.state.ready) await auth.restore()
if (to.meta.public) {
if (to.meta.guestOnly && auth.state.user) return '/dashboard'
return true
}
if (!auth.state.user) return { path: '/login', query: { redirect: to.fullPath } }
if (to.meta.admin && auth.state.user.role !== 'admin') return '/dashboard'
return true
})
export default router
+86
View File
@@ -0,0 +1,86 @@
import { reactive, computed } from 'vue'
import { authApi } from '@/api'
import { setToken, getToken, tryRefresh } from '@/api/client'
import type { LoginResult, User } from '@/types'
const state = reactive<{
user: User | null
ready: boolean
loading: boolean
}>({
user: null,
ready: false,
loading: false,
})
async function restore(): Promise<void> {
if (state.user) return
if (!getToken()) {
// No access token in storage: try the refresh cookie to recover a session.
const fresh = await tryRefresh()
if (!fresh) {
state.ready = true
return
}
setToken(fresh)
}
try {
state.user = await authApi.me()
} catch {
state.user = null
} finally {
state.ready = true
}
}
async function apply(result: LoginResult): Promise<void> {
setToken(result.token.accessToken)
state.user = result.user
}
async function login(account: string, password: string): Promise<void> {
state.loading = true
try {
await apply(await authApi.login(account, password))
} finally {
state.loading = false
}
}
async function register(data: { username: string; email: string; password: string; inviteCode?: string }): Promise<void> {
state.loading = true
try {
await apply(await authApi.register(data))
} finally {
state.loading = false
}
}
async function logout(): Promise<void> {
try {
await authApi.logout()
} catch {
// ignore network errors on logout
}
setToken('')
state.user = null
}
async function refreshProfile(): Promise<void> {
if (!getToken()) return
state.user = await authApi.me()
}
export function useAuth() {
return {
state,
user: computed(() => state.user),
isAdmin: computed(() => state.user?.role === 'admin'),
isAuthed: computed(() => !!state.user),
restore,
login,
register,
logout,
refreshProfile,
}
}
+60
View File
@@ -0,0 +1,60 @@
import { ref } from 'vue'
export type ThemeMode = 'light' | 'dark' | 'system'
export type EffectiveTheme = 'light' | 'dark'
const STORAGE_KEY = 'ot_theme'
const mq = window.matchMedia('(prefers-color-scheme: light)')
function loadMode(): ThemeMode {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'
} catch {
return 'system'
}
}
export const themeMode = ref<ThemeMode>(loadMode())
export const effectiveTheme = ref<EffectiveTheme>('dark')
function resolve(mode: ThemeMode): EffectiveTheme {
if (mode === 'light') return 'light'
if (mode === 'dark') return 'dark'
return mq.matches ? 'light' : 'dark'
}
function apply() {
effectiveTheme.value = resolve(themeMode.value)
document.documentElement.setAttribute('data-theme', effectiveTheme.value)
}
function onSystemChange() {
if (themeMode.value === 'system') apply()
}
export function setThemeMode(mode: ThemeMode) {
themeMode.value = mode
try {
localStorage.setItem(STORAGE_KEY, mode)
} catch {
/* 无痕/禁用存储时仅在内存生效 */
}
apply()
if (mode === 'system') mq.addEventListener('change', onSystemChange)
else mq.removeEventListener('change', onSystemChange)
}
// ECharts paints into a canvas and can't resolve `rgb(var(--x))`; return a
// concrete color string from the currently applied theme token.
export function themeColor(name: string, alpha?: number): string {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
if (!v) return name
const rgb = v.split(/\s+/).join(', ')
return alpha === undefined ? `rgb(${rgb})` : `rgba(${rgb}, ${alpha})`
}
// The inline <head> script already set data-theme pre-paint; re-sync here so
// the store owns live switching (incl. OS changes while mode is 'system').
apply()
if (themeMode.value === 'system') mq.addEventListener('change', onSystemChange)
+54
View File
@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply bg-bg text-ink antialiased;
}
body {
@apply min-h-screen font-sans;
}
::selection {
background: rgb(var(--t-accent) / 0.35);
}
/* slim scrollbars that match the dark theme */
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--t-border-strong)) transparent;
}
*::-webkit-scrollbar {
height: 8px;
width: 8px;
}
*::-webkit-scrollbar-thumb {
background: rgb(var(--t-border-strong));
border-radius: 9999px;
}
}
@layer components {
/* dense table primitives shared by user + admin tables */
.table-dense th {
@apply px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-mute;
}
.table-dense td {
@apply px-3 py-2.5 align-middle;
}
.table-dense tbody tr {
@apply border-t border-line;
}
.table-dense tbody tr:hover {
@apply bg-surface-2/60;
}
}
@layer utilities {
.mono {
@apply font-mono;
}
/* number/cost columns: right aligned, tabular so digits don't jump */
.num {
@apply font-mono tabular-nums;
}
}
+76
View File
@@ -0,0 +1,76 @@
/* Design tokens — themeable (dark default + light), dashboard feel for an
API gateway. Neutral slate scale + a single indigo accent; monospace for
tokens, endpoints and code. Values are raw RGB triplets consumed by
Tailwind via rgb(var(--x) / <alpha-value>). The effective theme is set as
`data-theme` on <html> by an inline script (see index.html) and kept in
sync by stores/theme.ts. */
:root {
color-scheme: dark;
/* surfaces */
--t-bg: 9 12 21; /* page background, near-black navy */
--t-surface: 17 22 36; /* cards / panels */
--t-surface-2: 24 31 48; /* raised elements, table headers */
--t-border: 37 45 66;
--t-border-strong: 57 67 96;
/* text */
--t-text: 228 232 240;
--t-text-soft: 148 163 184;
--t-text-mute: 100 116 139;
/* accent */
--t-accent: 99 102 241;
--t-accent-hover: 129 140 248;
--t-accent-soft: 99 102 241;
/* semantic */
--t-success: 52 211 153;
--t-warning: 251 191 36;
--t-danger: 248 113 113;
--t-info: 56 189 248;
/* typography */
--t-font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI',
'PingFang SC', 'Microsoft YaHei', sans-serif;
--t-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas,
'Liberation Mono', monospace;
/* shape */
--t-radius: 0.5rem;
--t-radius-sm: 0.375rem;
--t-shadow-card: 0 1px 2px rgb(0 0 0 / 0.4), 0 8px 24px -12px rgb(0 0 0 / 0.5);
--t-shadow-pop: 0 12px 40px -8px rgb(0 0 0 / 0.7);
}
[data-theme='light'] {
color-scheme: light;
/* surfaces */
--t-bg: 248 250 252; /* slate-50 page background */
--t-surface: 255 255 255; /* cards / panels */
--t-surface-2: 241 245 249; /* raised elements, table headers */
--t-border: 226 232 240; /* slate-200 */
--t-border-strong: 203 213 225; /* slate-300 */
/* text */
--t-text: 15 23 42; /* slate-900 */
--t-text-soft: 71 85 105; /* slate-600 */
--t-text-mute: 100 116 139; /* slate-500 */
/* accent — indigo-600/700 (darker than the dark theme for white bg) */
--t-accent: 79 70 229;
--t-accent-hover: 67 56 202;
--t-accent-soft: 99 102 241;
/* semantic — 600-level so they stay readable on white */
--t-success: 5 150 105;
--t-warning: 217 119 6;
--t-danger: 220 38 38;
--t-info: 2 132 199;
/* shape */
--t-shadow-card: 0 1px 2px rgb(15 23 42 / 0.05), 0 8px 24px -12px rgb(15 23 42 / 0.14);
--t-shadow-pop: 0 12px 40px -8px rgb(15 23 42 / 0.2);
}
+139
View File
@@ -0,0 +1,139 @@
// API types mirroring the Go backend DTOs.
export interface User {
id: number
username: string
email: string
role: 'admin' | 'user'
balance: string // decimal string
status: 'active' | 'disabled'
createdAt: string
lastLoginAt: string | null
}
export interface TokenPair {
accessToken: string
refreshToken: string
tokenType: string
expiresIn: number
}
export interface LoginResult {
user: User
token: TokenPair
}
export interface ApiKey {
id: number
userId: number
name: string
keyPrefix: string
quotaTokensPerDay: number | null
quotaRequestsPerDay: number | null
allowedModels: string[] | null
expiresAt: string | null
status: 'active' | 'revoked'
lastUsedAt: string | null
createdAt: string
}
export interface ApiKeyCreated extends ApiKey {
key: string // full key, returned only at creation
}
export interface UsageAgg {
requests: number
inputTokens: number
outputTokens: number
cost: string
}
export interface UsageSummary {
today: UsageAgg
month: UsageAgg
total: UsageAgg
}
export interface UsageStatRow {
key: string
requests: number
inputTokens: number
outputTokens: number
cost: string
}
export interface UsageLog {
id: number
requestId: string
model: string
channelId: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cost: string
latencyMs: number
status: string
errorCode: string
createdAt: string
}
export interface Page<T> {
total: number
page: number
pageSize: number
items: T[]
}
// API formats a channel can serve natively. Mirrors the proxy route protocols.
export type ApiFormat = 'openai-chat' | 'openai-responses' | 'anthropic'
export interface Channel {
id: number
name: string
provider: 'openai' | 'anthropic' | 'compatible'
baseUrl: string
formats: ApiFormat[]
weight: number
priority: number
timeoutMs: number
maxConcurrency: number
healthStatus: 'healthy' | 'degraded' | 'cooldown'
healthFailures: number
enabled: boolean
createdAt: string
updatedAt: string
}
export interface ChannelBinding {
id: number
modelId: number
modelName: string
upstreamModel: string
weight: number
}
export interface Model {
id: number
name: string
displayName: string
inputPrice: string
outputPrice: string
cacheReadPrice: string
enabled: boolean
sort: number
}
export interface AdminOverview {
total: { requests: number; cost: string; users: number; channels: number; models: number }
today: UsageAgg
month: { requests: number; cost: string }
}
export interface RechargeOrder {
id: number
userId: number
user?: User
amount: string
status: string
createdAt: string
}
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuth } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
import ThemeSwitch from '@/components/ui/ThemeSwitch.vue'
const { state } = useAuth()
const router = useRouter()
function go() {
router.push(state.user ? '/dashboard' : '/login')
}
</script>
<template>
<div class="relative flex min-h-screen flex-col overflow-hidden bg-bg">
<div class="pointer-events-none absolute -top-40 left-1/2 h-96 w-[36rem] -translate-x-1/2 rounded-full bg-brand/10 blur-3xl" />
<header class="relative z-10 mx-auto flex w-full max-w-5xl items-center justify-between px-6 py-5">
<div class="flex items-center gap-2">
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-brand text-white">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
</div>
<span class="font-mono text-base font-bold tracking-tight text-ink">openteam</span>
</div>
<div class="flex items-center gap-2">
<ThemeSwitch class="mr-1" />
<Button variant="ghost" @click="router.push('/login')">登录</Button>
<Button @click="router.push('/register')">注册</Button>
</div>
</header>
<main class="relative z-10 mx-auto flex w-full max-w-5xl flex-1 flex-col items-center px-6 pt-16 text-center">
<span class="mono rounded-full border border-line bg-surface px-3 py-1 text-xs text-brand">v0.3 · 自托管 LLM 网关</span>
<h1 class="mt-6 max-w-2xl text-4xl font-bold leading-tight tracking-tight text-ink sm:text-5xl">
统一接入主流 LLM 供应商的
<span class="text-brand">中继网关</span>
</h1>
<p class="mt-4 max-w-xl text-base text-ink-soft">
OpenAI、Anthropic 与兼容协议的流式接入,API 密钥管理、用量计费与多渠道容灾,开箱即用。
</p>
<div class="mt-10 flex items-center gap-3">
<Button size="md" @click="go">{{ state.user ? '进入控制台' : '立即开始' }}</Button>
<Button variant="outline" @click="router.push('/login')">查看文档</Button>
</div>
<div class="mt-16 grid w-full max-w-3xl grid-cols-1 gap-4 text-left sm:grid-cols-3">
<div class="rounded-lg border border-line bg-surface p-4">
<p class="text-xs font-medium uppercase tracking-wider text-brand">协议</p>
<p class="mt-2 text-sm text-ink-soft">Anthropic Messages / OpenAI Chat Completions / OpenAI Responses API 自动转换</p>
</div>
<div class="rounded-lg border border-line bg-surface p-4">
<p class="text-xs font-medium uppercase tracking-wider text-brand">渠道</p>
<p class="mt-2 text-sm text-ink-soft">多供应商加权路由、健康检查与故障转移</p>
</div>
<div class="rounded-lg border border-line bg-surface p-4">
<p class="text-xs font-medium uppercase tracking-wider text-brand">计费</p>
<p class="mt-2 text-sm text-ink-soft">按 token 精确计费,用量日志与余额实时可见</p>
</div>
</div>
</main>
<footer class="relative z-10 border-t border-line py-4 text-center text-xs text-ink-mute">
openteam relay gateway · PLANNING v0.3
</footer>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import ThemeSwitch from '@/components/ui/ThemeSwitch.vue'
import { toast } from '@/components/ui/toast'
const auth = useAuth()
const router = useRouter()
const route = useRoute()
const account = ref('')
const password = ref('')
const error = ref('')
const submitting = ref(false)
async function onSubmit() {
error.value = ''
if (!account.value || !password.value) {
error.value = '请输入账号与密码'
return
}
submitting.value = true
try {
await auth.login(account.value, password.value)
toast.success('登录成功')
const raw = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
const target = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/dashboard'
await router.push(target).catch(() => {})
// 兜底:某些浏览器/插件下 SPA 导航可能不生效,检测到仍在原页则整页跳转
if (router.currentRoute.value.path !== target.split('?')[0]) {
window.location.assign(target)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '登录失败'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="relative flex min-h-screen items-center justify-center bg-bg px-4">
<div class="absolute right-4 top-4">
<ThemeSwitch />
</div>
<div class="w-full max-w-sm">
<div class="mb-6 flex flex-col items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-lg bg-brand text-white">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
</div>
<div class="text-center">
<h1 class="text-xl font-semibold text-ink">登录 openteam</h1>
<p class="mt-1 text-sm text-ink-mute">使用你的账户访问控制台</p>
</div>
</div>
<form class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-card" @submit.prevent="onSubmit">
<Input v-model="account" label="账号 / 邮箱" placeholder="username or email" autocomplete="username" />
<Input v-model="password" label="密码" type="password" placeholder="••••••••" autocomplete="current-password" />
<p v-if="error" class="text-sm text-danger">{{ error }}</p>
<Button type="submit" block :loading="submitting">登录</Button>
</form>
<p class="mt-4 text-center text-sm text-ink-mute">
还没有账号?
<RouterLink to="/register" class="text-brand hover:text-brand-hover">立即注册</RouterLink>
</p>
</div>
</div>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import ThemeSwitch from '@/components/ui/ThemeSwitch.vue'
import { toast } from '@/components/ui/toast'
const auth = useAuth()
const router = useRouter()
const username = ref('')
const email = ref('')
const password = ref('')
const confirm = ref('')
const inviteCode = ref('')
const error = ref('')
const submitting = ref(false)
async function onSubmit() {
error.value = ''
if (!username.value || !password.value) {
error.value = '用户名与密码为必填项'
return
}
if (password.value.length < 8) {
error.value = '密码至少 8 位'
return
}
if (password.value !== confirm.value) {
error.value = '两次输入的密码不一致'
return
}
submitting.value = true
try {
await auth.register({
username: username.value,
email: email.value,
password: password.value,
inviteCode: inviteCode.value || undefined,
})
toast.success('注册成功,欢迎加入')
router.push('/dashboard')
} catch (e) {
error.value = e instanceof Error ? e.message : '注册失败'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="relative flex min-h-screen items-center justify-center bg-bg px-4 py-8">
<div class="absolute right-4 top-4">
<ThemeSwitch />
</div>
<div class="w-full max-w-sm">
<div class="mb-6 flex flex-col items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-lg bg-brand text-white">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
</div>
<div class="text-center">
<h1 class="text-xl font-semibold text-ink">创建账户</h1>
<p class="mt-1 text-sm text-ink-mute">注册后即可创建 API 密钥</p>
</div>
</div>
<form class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-card" @submit.prevent="onSubmit">
<Input v-model="username" label="用户名" placeholder="username" autocomplete="username" />
<Input v-model="email" label="邮箱" type="email" placeholder="you@example.com" autocomplete="email" />
<Input v-model="password" label="密码" type="password" placeholder="至少 8 位" autocomplete="new-password" />
<Input v-model="confirm" label="确认密码" type="password" placeholder="再次输入密码" autocomplete="new-password" />
<Input v-model="inviteCode" label="邀请码(可选)" placeholder="邀请码" />
<p v-if="error" class="text-sm text-danger">{{ error }}</p>
<Button type="submit" block :loading="submitting">注册</Button>
</form>
<p class="mt-4 text-center text-sm text-ink-mute">
已有账号?
<RouterLink to="/login" class="text-brand hover:text-brand-hover">去登录</RouterLink>
</p>
</div>
</div>
</template>
+388
View File
@@ -0,0 +1,388 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Table, { type TableColumn } from '@/components/ui/Table.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import { adminApi } from '@/api'
import type { Channel, ChannelBinding } from '@/types'
import type { ApiFormat } from '@/types'
import { API_FORMATS, API_FORMAT_LABELS } from '@/lib/apiFormats'
import { toast } from '@/components/ui/toast'
const channels = ref<Channel[]>([])
const loading = ref(true)
const editorOpen = ref(false)
const editing = ref<Channel | null>(null)
const saving = ref(false)
const formError = ref('')
const bindingsOpen = ref(false)
const bindingsChannel = ref<Channel | null>(null)
const bindings = ref<ChannelBinding[]>([])
const bindingsLoading = ref(false)
const testing = ref<number | null>(null)
const healthTone: Record<string, 'ok' | 'warn' | 'danger'> = { healthy: 'ok', degraded: 'warn', cooldown: 'danger' }
const healthLabel: Record<string, string> = { healthy: '健康', degraded: '降级', cooldown: '冷却' }
const form = reactive({
name: '',
formats: ['openai-chat', 'openai-responses'] as ApiFormat[],
baseUrl: '',
apiKey: '',
weight: '10',
priority: '0',
timeoutMs: '60000',
maxConcurrency: '10',
enabled: true,
})
const columns: TableColumn[] = [
{ key: 'name', label: '名称' },
{ key: 'formats', label: 'API 格式' },
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'weight', label: '权重' },
{ key: 'healthStatus', label: '健康状态' },
{ key: 'enabled', label: '启用' },
{ key: 'actions', label: '操作', align: 'right' },
]
async function load() {
loading.value = true
try {
channels.value = await adminApi.channels.list()
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
onMounted(load)
function openCreate() {
editing.value = null
Object.assign(form, {
name: '',
formats: ['openai-chat', 'openai-responses'] as ApiFormat[],
baseUrl: '',
apiKey: '',
weight: '10',
priority: '0',
timeoutMs: '60000',
maxConcurrency: '10',
enabled: true,
})
formError.value = ''
editorOpen.value = true
}
function openEdit(ch: Channel) {
editing.value = ch
Object.assign(form, {
name: ch.name,
formats: [...(ch.formats ?? [])],
baseUrl: ch.baseUrl,
apiKey: '',
weight: String(ch.weight),
priority: String(ch.priority),
timeoutMs: String(ch.timeoutMs),
maxConcurrency: String(ch.maxConcurrency),
enabled: ch.enabled,
})
formError.value = ''
editorOpen.value = true
}
function toggleFormat(f: ApiFormat) {
const i = form.formats.indexOf(f)
if (i >= 0) form.formats.splice(i, 1)
else form.formats.push(f)
}
async function onSave() {
formError.value = ''
if (!form.name.trim() || !form.baseUrl.trim()) {
formError.value = '名称与 Base URL 为必填项'
return
}
if (form.formats.length === 0) {
formError.value = '请至少选择一种 API 格式'
return
}
if (!editing.value && !form.apiKey.trim()) {
formError.value = '请填写渠道 API Key'
return
}
saving.value = true
try {
const hasAnthropic = form.formats.includes('anthropic')
const hasOpenAI = form.formats.some((f) => f === 'openai-chat' || f === 'openai-responses')
const payload = {
name: form.name.trim(),
formats: [...form.formats],
provider: (hasAnthropic && !hasOpenAI ? 'anthropic' : 'openai') as Channel['provider'],
baseUrl: form.baseUrl.trim(),
weight: parseInt(form.weight, 10) || 1,
priority: parseInt(form.priority, 10) || 0,
timeoutMs: parseInt(form.timeoutMs, 10) || 60000,
maxConcurrency: parseInt(form.maxConcurrency, 10) || 10,
enabled: form.enabled,
}
if (editing.value) {
await adminApi.channels.update(editing.value.id, payload)
toast.success('渠道已更新')
} else {
await adminApi.channels.create({ ...payload, apiKey: form.apiKey.trim() })
toast.success('渠道已创建')
}
editorOpen.value = false
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function onToggle(ch: Channel) {
try {
await adminApi.channels.update(ch.id, { enabled: !ch.enabled })
toast.success(!ch.enabled ? '已启用' : '已停用')
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
}
}
async function onTest(ch: Channel) {
testing.value = ch.id
try {
const r = await adminApi.channels.test(ch.id)
if (r.ok) toast.success(`测试成功 · ${r.model} · ${r.latencyMs}ms`)
else toast.warn('测试返回失败')
} catch (e) {
toast.error(e instanceof Error ? e.message : '测试失败')
} finally {
testing.value = null
}
}
async function onRemove(ch: Channel) {
if (!window.confirm(`确定删除渠道「${ch.name}」?`)) return
try {
await adminApi.channels.remove(ch.id)
toast.success('已删除')
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '删除失败')
}
}
async function onImportModels(ch: Channel) {
try {
const r = await adminApi.channels.importModels(ch.id)
toast.success(`导入 ${r.count} 个模型:${r.imported.join(', ')}`)
await openBindings(ch)
} catch (e) {
toast.error(e instanceof Error ? e.message : '导入失败')
}
}
async function openBindings(ch: Channel) {
bindingsChannel.value = ch
bindingsOpen.value = true
bindingsLoading.value = true
try {
bindings.value = await adminApi.channels.bindings(ch.id)
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载绑定失败')
} finally {
bindingsLoading.value = false
}
}
function setBindingUpstream(b: ChannelBinding, v: string) {
b.upstreamModel = v
}
function setBindingWeight(b: ChannelBinding, v: string) {
b.weight = parseInt(v, 10) || 1
}
async function saveBindings() {
if (!bindingsChannel.value) return
bindingsLoading.value = true
try {
await adminApi.channels.saveBindings(
bindingsChannel.value.id,
bindings.value.map((b) => ({ modelId: b.modelId, upstreamModel: b.upstreamModel, weight: b.weight })),
)
toast.success('绑定已保存')
bindingsOpen.value = false
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败')
} finally {
bindingsLoading.value = false
}
}
</script>
<template>
<div>
<PageHeader title="渠道管理" desc="上游供应商渠道与健康状态">
<template #actions>
<Button @click="openCreate">新建渠道</Button>
</template>
</PageHeader>
<Card padded>
<Table :columns="columns" :rows="channels" :loading="loading" empty-text="还没有渠道">
<template #col-name="{ row }">
<span class="font-medium text-ink">{{ row.name }}</span>
</template>
<template #col-formats="{ row }">
<div class="flex flex-wrap gap-1">
<Badge
v-for="f in row.formats ?? []"
:key="f"
:tone="f === 'anthropic' ? 'info' : 'neutral'"
>
{{ API_FORMAT_LABELS[f as ApiFormat] || f }}
</Badge>
</div>
</template>
<template #col-baseUrl="{ row }">
<code class="mono text-xs text-ink-soft">{{ row.baseUrl }}</code>
</template>
<template #col-weight="{ row }">
<span class="num text-xs text-ink-soft">{{ row.weight }}</span>
</template>
<template #col-healthStatus="{ row }">
<Badge :tone="healthTone[row.healthStatus] || 'neutral'">{{ healthLabel[row.healthStatus] || row.healthStatus }}</Badge>
</template>
<template #col-enabled="{ row }">
<Badge :tone="row.enabled ? 'ok' : 'neutral'">{{ row.enabled ? '启用' : '停用' }}</Badge>
</template>
<template #col-actions="{ row }">
<div class="flex justify-end gap-1">
<Button variant="ghost" size="xs" :loading="testing === row.id" @click="onTest(row)">测试</Button>
<Button variant="ghost" size="xs" @click="openBindings(row)">绑定</Button>
<Button variant="ghost" size="xs" @click="onImportModels(row)">导入模型</Button>
<Button variant="ghost" size="xs" @click="openEdit(row)">编辑</Button>
<Button variant="ghost" size="xs" @click="onToggle(row)">{{ row.enabled ? '停用' : '启用' }}</Button>
<Button variant="danger" size="xs" @click="onRemove(row)">删除</Button>
</div>
</template>
</Table>
</Card>
<!-- create / edit -->
<Modal :open="editorOpen" :title="editing ? '编辑渠道' : '新建渠道'" width="max-w-md" @close="editorOpen = false">
<form id="channel-form" class="space-y-4" @submit.prevent="onSave">
<Input v-model="form.name" label="名称" placeholder="例如:OpenAI 官方" />
<div class="space-y-1.5">
<p class="text-xs font-medium text-ink-soft">支持的 API 格式</p>
<div class="flex flex-col gap-1.5">
<label
v-for="opt in API_FORMATS"
:key="opt.value"
class="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2 text-sm transition-colors"
:class="
form.formats.includes(opt.value)
? 'border-brand bg-brand/10 text-ink'
: 'border-line bg-bg text-ink-soft hover:border-border-strong'
"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-line bg-bg accent-brand"
:checked="form.formats.includes(opt.value)"
@change="toggleFormat(opt.value)"
/>
<span class="flex-1 leading-tight">
<span class="block font-medium">{{ opt.label }}</span>
<span class="block text-xs text-ink-mute">{{ opt.desc }}</span>
</span>
</label>
</div>
<p class="text-xs text-ink-mute">未选择的格式会由网关自动转换后转发。</p>
</div>
<Input v-model="form.baseUrl" label="Base URL" placeholder="https://api.openai.com/v1" mono />
<Input v-model="form.apiKey" label="API Key" :placeholder="editing ? '留空保持不变' : 'sk-…'" mono autocomplete="off" />
<div class="grid grid-cols-2 gap-3">
<Input v-model="form.weight" label="权重" placeholder="10" />
<Input v-model="form.priority" label="优先级" placeholder="0" hint="数值越大越优先" />
</div>
<div class="grid grid-cols-2 gap-3">
<Input v-model="form.timeoutMs" label="超时 (ms)" placeholder="60000" />
<Input v-model="form.maxConcurrency" label="最大并发" placeholder="10" />
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm text-ink-soft">
<input v-model="form.enabled" type="checkbox" class="h-4 w-4 rounded border-line bg-bg accent-brand" />
启用该渠道
</label>
<p v-if="formError" class="text-sm text-danger">{{ formError }}</p>
</form>
<template #footer>
<Button variant="ghost" @click="editorOpen = false">取消</Button>
<Button type="submit" form="channel-form" :loading="saving">保存</Button>
</template>
</Modal>
<!-- bindings -->
<Modal
:open="bindingsOpen"
:title="`模型绑定 · ${bindingsChannel?.name ?? ''}`"
width="max-w-2xl"
@close="bindingsOpen = false"
>
<div v-if="bindingsLoading && !bindings.length" class="py-8 text-center text-sm text-ink-mute">加载中…</div>
<template v-else>
<div v-if="!bindings.length" class="py-8 text-center text-sm text-ink-mute">
暂无绑定,可点击「导入模型」从上游自动拉取。
</div>
<table v-else class="table-dense w-full text-sm">
<thead>
<tr>
<th>模型</th>
<th>上游模型</th>
<th class="text-right">权重</th>
</tr>
</thead>
<tbody>
<tr v-for="b in bindings" :key="b.id">
<td class="text-ink">{{ b.modelName }}</td>
<td>
<input
:value="b.upstreamModel"
class="mono w-full rounded border border-line bg-bg px-2 py-1 text-xs text-ink focus:border-brand focus:outline-none"
@input="setBindingUpstream(b, ($event.target as HTMLInputElement).value)"
/>
</td>
<td class="text-right">
<input
:value="b.weight"
type="number"
min="1"
class="mono w-20 rounded border border-line bg-bg px-2 py-1 text-right text-xs text-ink focus:border-brand focus:outline-none"
@input="setBindingWeight(b, ($event.target as HTMLInputElement).value)"
/>
</td>
</tr>
</tbody>
</table>
</template>
<template #footer>
<Button variant="ghost" @click="bindingsOpen = false">取消</Button>
<Button :loading="bindingsLoading" @click="saveBindings">保存绑定</Button>
</template>
</Modal>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import { adminApi } from '@/api'
import { toast } from '@/components/ui/toast'
const loading = ref(true)
const saving = ref(false)
const values = reactive<Record<string, string | boolean>>({})
const dirty = ref(false)
async function load() {
loading.value = true
try {
const cfg = await adminApi.config.get()
for (const k of Object.keys(values)) delete values[k]
for (const [k, v] of Object.entries(cfg)) {
values[k] = typeof v === 'boolean' ? v : String(v)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载配置失败')
} finally {
loading.value = false
}
}
onMounted(load)
function setValue(k: string, v: string) {
values[k] = v
dirty.value = true
}
function setBool(k: string, v: boolean) {
values[k] = v
dirty.value = true
}
async function onSave() {
saving.value = true
try {
const payload: Record<string, unknown> = {}
for (const [k, v] of Object.entries(values)) {
if (typeof v === 'boolean') {
payload[k] = v
continue
}
const n = Number(v)
payload[k] = v !== '' && v.trim() !== '' && Number.isFinite(n) ? n : v
}
await adminApi.config.put(payload)
dirty.value = false
toast.success('配置已保存')
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
function entries(): [string, string | boolean][] {
return Object.entries(values).sort((a, b) => a[0].localeCompare(b[0]))
}
const safeKeys: Record<string, boolean> = {
server_port: true,
server_host: true,
auth_invite_required: true,
auth_default_quota: true,
auth_register_enabled: true,
proxy_max_retries: true,
proxy_timeout_ms: true,
channel_healthcheck_interval_s: true,
channel_healthcheck_max_failures: true,
channel_healthcheck_cooldown_s: true,
billing_enabled: true,
}
</script>
<template>
<div>
<PageHeader title="系统配置" desc="运行时配置项(保存后重启部分项生效)">
<template #actions>
<Button :loading="saving" :disabled="!dirty" @click="onSave">保存</Button>
</template>
</PageHeader>
<Card padded>
<div v-if="loading" class="py-16 text-center text-sm text-ink-mute">加载中…</div>
<div v-else-if="!entries().length" class="py-16 text-center text-sm text-ink-mute">暂无配置项</div>
<div v-else class="divide-y divide-line">
<div v-for="[k, v] in entries()" :key="k" class="flex items-center justify-between gap-4 py-3">
<div class="min-w-0">
<code class="mono text-sm text-ink">{{ k }}</code>
<p v-if="safeKeys[k]" class="text-xs text-ink-mute">可安全修改</p>
</div>
<div class="w-64 shrink-0">
<label v-if="typeof v === 'boolean'" class="flex cursor-pointer items-center justify-end gap-2 text-sm text-ink-soft">
<input
type="checkbox"
class="h-4 w-4 rounded border-line bg-bg accent-brand"
:checked="v"
@change="setBool(k, ($event.target as HTMLInputElement).checked)"
/>
{{ v ? '是' : '否' }}
</label>
<Input v-else :model-value="v as string" :mono="true" @update:model-value="(nv: string) => setValue(k, nv)" />
</div>
</div>
</div>
</Card>
</div>
</template>
+200
View File
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Table, { type TableColumn } from '@/components/ui/Table.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import { adminApi } from '@/api'
import type { Model } from '@/types'
import { toast } from '@/components/ui/toast'
const models = ref<Model[]>([])
const loading = ref(true)
const editorOpen = ref(false)
const editing = ref<Model | null>(null)
const saving = ref(false)
const formError = ref('')
const form = reactive({
name: '',
displayName: '',
inputPrice: '0.000001',
outputPrice: '0.000002',
cacheReadPrice: '0',
enabled: true,
sort: '0',
})
const columns: TableColumn[] = [
{ key: 'name', label: '模型' },
{ key: 'displayName', label: '显示名' },
{ key: 'inputPrice', label: '输入单价' },
{ key: 'outputPrice', label: '输出单价' },
{ key: 'cacheReadPrice', label: '缓存读' },
{ key: 'enabled', label: '状态' },
{ key: 'actions', label: '操作', align: 'right' },
]
async function load() {
loading.value = true
try {
models.value = await adminApi.models.list()
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
onMounted(load)
function openCreate() {
editing.value = null
Object.assign(form, {
name: '',
displayName: '',
inputPrice: '0.000001',
outputPrice: '0.000002',
cacheReadPrice: '0',
enabled: true,
sort: '0',
})
formError.value = ''
editorOpen.value = true
}
function openEdit(m: Model) {
editing.value = m
Object.assign(form, {
name: m.name,
displayName: m.displayName,
inputPrice: m.inputPrice,
outputPrice: m.outputPrice,
cacheReadPrice: m.cacheReadPrice,
enabled: m.enabled,
sort: String(m.sort),
})
formError.value = ''
editorOpen.value = true
}
async function onSave() {
formError.value = ''
if (!form.name.trim()) {
formError.value = '模型名为必填项'
return
}
saving.value = true
try {
const payload = {
name: form.name.trim(),
displayName: form.displayName.trim(),
inputPrice: form.inputPrice || '0',
outputPrice: form.outputPrice || '0',
cacheReadPrice: form.cacheReadPrice || '0',
enabled: form.enabled,
sort: parseInt(form.sort, 10) || 0,
}
if (editing.value) {
await adminApi.models.update(editing.value.id, payload)
await adminApi.models.price(editing.value.id, {
inputPrice: payload.inputPrice,
outputPrice: payload.outputPrice,
cacheReadPrice: payload.cacheReadPrice,
})
toast.success('模型已更新')
} else {
await adminApi.models.create(payload)
toast.success('模型已创建')
}
editorOpen.value = false
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function onToggle(m: Model) {
try {
await adminApi.models.update(m.id, { enabled: !m.enabled })
toast.success(!m.enabled ? '已启用' : '已停用')
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
}
}
function fmtPrice(p: string): string {
const n = Number(p)
return n === 0 ? '免费' : `$${n.toFixed(n < 0.001 ? 8 : 6)}`
}
</script>
<template>
<div>
<PageHeader title="模型定价" desc="模型定义与按 token 计费单价(每 1M tokens)">
<template #actions>
<Button @click="openCreate">新建模型</Button>
</template>
</PageHeader>
<Card padded>
<Table :columns="columns" :rows="models" :loading="loading" empty-text="还没有模型">
<template #col-name="{ row }">
<code class="mono text-sm font-medium text-ink">{{ row.name }}</code>
</template>
<template #col-displayName="{ row }">
<span class="text-sm text-ink-soft">{{ row.displayName || '—' }}</span>
</template>
<template #col-inputPrice="{ row }">
<span class="num text-xs text-ink-soft">{{ fmtPrice(row.inputPrice) }}</span>
</template>
<template #col-outputPrice="{ row }">
<span class="num text-xs text-ink-soft">{{ fmtPrice(row.outputPrice) }}</span>
</template>
<template #col-cacheReadPrice="{ row }">
<span class="num text-xs text-ink-mute">{{ fmtPrice(row.cacheReadPrice) }}</span>
</template>
<template #col-enabled="{ row }">
<Badge :tone="row.enabled ? 'ok' : 'neutral'">{{ row.enabled ? '启用' : '停用' }}</Badge>
</template>
<template #col-actions="{ row }">
<div class="flex justify-end gap-1">
<Button variant="ghost" size="xs" @click="openEdit(row)">编辑</Button>
<Button variant="ghost" size="xs" @click="onToggle(row)">{{ row.enabled ? '停用' : '启用' }}</Button>
</div>
</template>
</Table>
</Card>
<Modal :open="editorOpen" :title="editing ? '编辑模型' : '新建模型'" width="max-w-md" @close="editorOpen = false">
<form id="model-form" class="space-y-4" @submit.prevent="onSave">
<Input v-model="form.name" label="模型名" placeholder="gpt-test" mono hint="客户端请求时使用的 model 名" />
<Input v-model="form.displayName" label="显示名" placeholder="可选" />
<div class="grid grid-cols-2 gap-3">
<Input v-model="form.inputPrice" label="输入单价 (per 1M)" mono />
<Input v-model="form.outputPrice" label="输出单价 (per 1M)" mono />
</div>
<div class="grid grid-cols-2 gap-3">
<Input v-model="form.cacheReadPrice" label="缓存读单价 (per 1M)" mono />
<Input v-model="form.sort" label="排序" placeholder="0" />
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm text-ink-soft">
<input v-model="form.enabled" type="checkbox" class="h-4 w-4 rounded border-line bg-bg accent-brand" />
启用该模型
</label>
<p v-if="formError" class="text-sm text-danger">{{ formError }}</p>
</form>
<template #footer>
<Button variant="ghost" @click="editorOpen = false">取消</Button>
<Button type="submit" form="model-form" :loading="saving">保存</Button>
</template>
</Modal>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import Stat from '@/components/ui/Stat.vue'
import Card from '@/components/ui/Card.vue'
import { adminApi } from '@/api'
import type { AdminOverview } from '@/types'
import { toast } from '@/components/ui/toast'
const data = ref<AdminOverview | null>(null)
const loading = ref(true)
onMounted(async () => {
try {
data.value = await adminApi.overview()
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
})
function fmtCost(c: string | undefined): string {
if (c === undefined) return '—'
const n = Number(c)
return n === 0 ? '0' : n.toFixed(n < 0.01 ? 6 : 4)
}
</script>
<template>
<div>
<PageHeader title="总览" desc="网关整体运行数据" />
<div v-if="loading" class="py-20 text-center text-sm text-ink-mute">加载中…</div>
<template v-else>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
<Stat label="累计请求" :value="data ? String(data.total.requests) : '—'" />
<Stat label="累计成本" :value="data ? `$${fmtCost(data.total.cost)}` : '—'" tone="info" mono />
<Stat label="用户数" :value="data ? String(data.total.users) : '—'" />
<Stat label="渠道数" :value="data ? String(data.total.channels) : '—'" />
<Stat label="模型数" :value="data ? String(data.total.models) : '—'" />
</div>
<div class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card title="今日" desc="过去 24 小时">
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-ink-mute">请求</span>
<span class="num text-ink">{{ data?.today.requests ?? '—' }}</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">输入 Token</span>
<span class="num text-ink">{{ data?.today.inputTokens.toLocaleString('en-US') ?? '—' }}</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">输出 Token</span>
<span class="num text-ink">{{ data?.today.outputTokens.toLocaleString('en-US') ?? '—' }}</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">成本</span>
<span class="num text-info">${{ fmtCost(data?.today.cost) }}</span>
</div>
</div>
</Card>
<Card title="本月" desc="自然月累计">
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-ink-mute">请求</span>
<span class="num text-ink">{{ data?.month.requests ?? '—' }}</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">成本</span>
<span class="num text-info">${{ fmtCost(data?.month.cost) }}</span>
</div>
</div>
</Card>
<Card title="运行状态">
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-ink-mute">网关</span>
<span class="text-ok">正常</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">数据库</span>
<span class="text-ok">正常</span>
</div>
<p class="text-xs text-ink-mute">渠道健康状态请前往「渠道管理」查看。</p>
</div>
</Card>
</div>
</template>
</div>
</template>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import VChart from 'vue-echarts'
import type { ComposeOption } from 'echarts/core'
import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts'
import type { GridComponentOption, TooltipComponentOption, LegendComponentOption } from 'echarts/components'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Table, { type TableColumn } from '@/components/ui/Table.vue'
import Input from '@/components/ui/Input.vue'
import Select, { type SelectOption } from '@/components/ui/Select.vue'
import Button from '@/components/ui/Button.vue'
import { adminApi } from '@/api'
import type { UsageStatRow } from '@/types'
import { toast } from '@/components/ui/toast'
import { effectiveTheme, themeColor } from '@/stores/theme'
type ECOption = ComposeOption<
BarSeriesOption | LineSeriesOption | GridComponentOption | TooltipComponentOption | LegendComponentOption
>
const rows = ref<UsageStatRow[]>([])
const loading = ref(true)
const from = ref('')
const to = ref('')
const group = ref('day')
const model = ref('')
const groupOptions: SelectOption[] = [
{ label: '按天', value: 'day' },
{ label: '按模型', value: 'model' },
{ label: '按用户', value: 'user' },
]
const columns: TableColumn[] = [
{ key: 'key', label: '维度' },
{ key: 'requests', label: '请求' },
{ key: 'inputTokens', label: '输入 Token' },
{ key: 'outputTokens', label: '输出 Token' },
{ key: 'cost', label: '成本' },
]
const totals = computed(() => {
return rows.value.reduce(
(acc, r) => {
acc.requests += r.requests
acc.inputTokens += r.inputTokens
acc.outputTokens += r.outputTokens
acc.cost += Number(r.cost)
return acc
},
{ requests: 0, inputTokens: 0, outputTokens: 0, cost: 0 },
)
})
const chartOption = computed<ECOption>(() => {
effectiveTheme.value // repaint when the theme switches
const data = rows.value
const byKey = group.value === 'day'
return {
tooltip: {
trigger: 'axis',
backgroundColor: themeColor('--t-surface-2'),
borderColor: themeColor('--t-border-strong'),
textStyle: { color: themeColor('--t-text') },
},
grid: { left: 8, right: 8, top: 24, bottom: 0, containLabel: true },
xAxis: {
type: 'category',
data: data.map((r) => (byKey ? r.key : String(r.key).slice(0, 24))),
axisLine: { lineStyle: { color: themeColor('--t-border') } },
axisLabel: { color: themeColor('--t-text-mute'), interval: byKey ? 'auto' : 0, rotate: byKey ? 0 : 30 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: themeColor('--t-border', 0.5) } },
axisLabel: { color: themeColor('--t-text-mute') },
},
series: [
{
name: '请求数',
type: 'bar',
barMaxWidth: 28,
itemStyle: { color: themeColor('--t-accent', 0.85), borderRadius: [3, 3, 0, 0] },
data: data.map((r) => r.requests),
},
],
}
})
async function load() {
loading.value = true
try {
rows.value = await adminApi.usage.rows({
from: from.value || undefined,
to: to.value || undefined,
group: group.value as 'day' | 'model' | 'user',
model: model.value || undefined,
})
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
function apply() {
load()
}
onMounted(load)
function fmtNum(n: number): string {
return n.toLocaleString('en-US')
}
</script>
<template>
<div>
<PageHeader title="用量统计" desc="全局请求与费用聚合" />
<div class="mb-4 flex flex-wrap items-end gap-3">
<div class="w-40">
<Input v-model="from" label="开始日期" type="date" />
</div>
<div class="w-40">
<Input v-model="to" label="结束日期" type="date" />
</div>
<div class="w-36">
<Select v-model="group" label="聚合维度" :options="groupOptions" />
</div>
<div class="w-48">
<Input v-model="model" label="模型" placeholder="全部模型" />
</div>
<Button @click="apply">查询</Button>
</div>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card padded>
<p class="text-xs text-ink-mute">请求总数</p>
<p class="num mt-1.5 text-xl font-semibold text-ink">{{ fmtNum(totals.requests) }}</p>
</Card>
<Card padded>
<p class="text-xs text-ink-mute">输入 Token</p>
<p class="num mt-1.5 text-xl font-semibold text-ink">{{ fmtNum(totals.inputTokens) }}</p>
</Card>
<Card padded>
<p class="text-xs text-ink-mute">输出 Token</p>
<p class="num mt-1.5 text-xl font-semibold text-ink">{{ fmtNum(totals.outputTokens) }}</p>
</Card>
<Card padded>
<p class="text-xs text-ink-mute">总成本</p>
<p class="num mt-1.5 text-xl font-semibold text-info">${{ totals.cost.toFixed(6) }}</p>
</Card>
</div>
<Card title="分布" class="mt-4">
<div class="h-64">
<VChart v-if="rows.length" :option="chartOption" autoresize />
<div v-else-if="!loading" class="flex h-full items-center justify-center text-sm text-ink-mute">暂无数据</div>
</div>
</Card>
<Card title="明细" class="mt-4">
<Table :columns="columns" :rows="rows" :loading="loading" empty-text="该条件下没有数据">
<template #col-key="{ row }">
<code class="mono text-sm text-ink">{{ row.key }}</code>
</template>
<template #col-requests="{ row }">
<span class="num text-sm text-ink-soft">{{ fmtNum(row.requests) }}</span>
</template>
<template #col-inputTokens="{ row }">
<span class="num text-sm text-ink-soft">{{ fmtNum(row.inputTokens) }}</span>
</template>
<template #col-outputTokens="{ row }">
<span class="num text-sm text-ink-soft">{{ fmtNum(row.outputTokens) }}</span>
</template>
<template #col-cost="{ row }">
<span class="num text-sm text-info">${{ Number(row.cost).toFixed(6) }}</span>
</template>
</Table>
</Card>
</div>
</template>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Table, { type TableColumn } from '@/components/ui/Table.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import { adminApi } from '@/api'
import type { User } from '@/types'
import { useAuth } from '@/stores/auth'
import { toast } from '@/components/ui/toast'
const { state } = useAuth()
const users = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const loading = ref(true)
const balanceOpen = ref(false)
const balanceTarget = ref<User | null>(null)
const balanceForm = reactive({ amount: '', remark: '' })
const balanceError = ref('')
const balanceSaving = ref(false)
const columns: TableColumn[] = [
{ key: 'id', label: 'ID' },
{ key: 'username', label: '用户名' },
{ key: 'email', label: '邮箱' },
{ key: 'role', label: '角色' },
{ key: 'balance', label: '余额' },
{ key: 'status', label: '状态' },
{ key: 'createdAt', label: '注册时间' },
{ key: 'actions', label: '操作', align: 'right' },
]
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
async function load() {
loading.value = true
try {
const r = await adminApi.users.list(page.value)
users.value = r.items
total.value = r.total
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
onMounted(load)
function go(p: number) {
if (p < 1 || p > totalPages.value) return
page.value = p
load()
}
async function toggleRole(u: User) {
const next = u.role === 'admin' ? 'user' : 'admin'
if (u.id === state.user?.id) {
toast.warn('不能修改自己的角色')
return
}
if (!window.confirm(`将「${u.username}」的角色设为${next === 'admin' ? '管理员' : '普通用户'}?`)) return
try {
await adminApi.users.update(u.id, { role: next })
toast.success('已更新角色')
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
}
}
async function toggleStatus(u: User) {
if (u.id === state.user?.id) {
toast.warn('不能停用自己')
return
}
const next = u.status === 'active' ? 'disabled' : 'active'
try {
await adminApi.users.update(u.id, { status: next })
toast.success(next === 'active' ? '已启用' : '已停用')
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
}
}
function openBalance(u: User) {
balanceTarget.value = u
balanceForm.amount = ''
balanceForm.remark = ''
balanceError.value = ''
balanceOpen.value = true
}
async function saveBalance() {
if (!balanceTarget.value) return
balanceError.value = ''
const amount = parseFloat(balanceForm.amount)
if (!Number.isFinite(amount) || amount === 0) {
balanceError.value = '请输入非零金额(正数充值,负数扣减)'
return
}
balanceSaving.value = true
try {
const r = await adminApi.users.adjustBalance(balanceTarget.value.id, String(amount), balanceForm.remark || undefined)
toast.success(`调整完成,新余额 $${r.balanceAfter}`)
balanceOpen.value = false
await load()
} catch (e) {
balanceError.value = e instanceof Error ? e.message : '操作失败'
} finally {
balanceSaving.value = false
}
}
function fmtDate(s: string): string {
return new Date(s).toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<div>
<PageHeader title="用户管理" desc="查看与调整用户账户" />
<Card padded>
<Table :columns="columns" :rows="users" :loading="loading" empty-text="没有用户">
<template #col-id="{ row }">
<span class="num text-xs text-ink-mute">{{ row.id }}</span>
</template>
<template #col-username="{ row }">
<span class="font-medium text-ink">{{ row.username }}</span>
</template>
<template #col-email="{ row }">
<span class="text-xs text-ink-soft">{{ row.email || '—' }}</span>
</template>
<template #col-role="{ row }">
<Badge :tone="row.role === 'admin' ? 'info' : 'neutral'">{{ row.role === 'admin' ? '管理员' : '用户' }}</Badge>
</template>
<template #col-balance="{ row }">
<span class="num text-sm text-ok">${{ row.balance }}</span>
</template>
<template #col-status="{ row }">
<Badge :tone="row.status === 'active' ? 'ok' : 'danger'">{{ row.status === 'active' ? '正常' : '停用' }}</Badge>
</template>
<template #col-createdAt="{ row }">
<span class="text-xs text-ink-soft">{{ fmtDate(row.createdAt) }}</span>
</template>
<template #col-actions="{ row }">
<div class="flex justify-end gap-1">
<Button variant="ghost" size="xs" @click="openBalance(row)">调整余额</Button>
<Button variant="ghost" size="xs" @click="toggleRole(row)">
{{ row.role === 'admin' ? '降级' : '设为管理员' }}
</Button>
<Button variant="ghost" size="xs" @click="toggleStatus(row)">
{{ row.status === 'active' ? '停用' : '启用' }}
</Button>
</div>
</template>
</Table>
<div v-if="total > pageSize" class="mt-4 flex items-center justify-between">
<span class="text-xs text-ink-mute">共 {{ total }} 位用户</span>
<div class="flex items-center gap-2">
<Button variant="outline" size="xs" :disabled="page <= 1" @click="go(page - 1)">上一页</Button>
<span class="num text-xs text-ink-soft">{{ page }} / {{ totalPages }}</span>
<Button variant="outline" size="xs" :disabled="page >= totalPages" @click="go(page + 1)">下一页</Button>
</div>
</div>
</Card>
<Modal
:open="balanceOpen"
:title="`调整余额 · ${balanceTarget?.username ?? ''}`"
width="max-w-sm"
@close="balanceOpen = false"
>
<form id="balance-form" class="space-y-4" @submit.prevent="saveBalance">
<p class="text-xs text-ink-mute">当前余额:<span class="mono text-ok">${{ balanceTarget?.balance }}</span></p>
<Input v-model="balanceForm.amount" label="金额" placeholder="例如 10 或 -5" mono hint="正数充值,负数扣减" />
<Input v-model="balanceForm.remark" label="备注(可选)" placeholder="例如:手动退款" />
<p v-if="balanceError" class="text-sm text-danger">{{ balanceError }}</p>
</form>
<template #footer>
<Button variant="ghost" @click="balanceOpen = false">取消</Button>
<Button type="submit" form="balance-form" :loading="balanceSaving">确认</Button>
</template>
</Modal>
</div>
</template>
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import VChart from 'vue-echarts'
import type { ComposeOption } from 'echarts/core'
import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts'
import type { GridComponentOption, TooltipComponentOption, LegendComponentOption } from 'echarts/components'
import PageHeader from '@/components/ui/PageHeader.vue'
import Stat from '@/components/ui/Stat.vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import { useAuth } from '@/stores/auth'
import { effectiveTheme, themeColor } from '@/stores/theme'
import { usageApi, keysApi } from '@/api'
import type { UsageStatRow, UsageSummary } from '@/types'
type ECOption = ComposeOption<
BarSeriesOption | LineSeriesOption | GridComponentOption | TooltipComponentOption | LegendComponentOption
>
const { state } = useAuth()
const summary = ref<UsageSummary | null>(null)
const stats = ref<UsageStatRow[]>([])
const loading = ref(true)
const hasKey = ref(false)
const origin = window.location.origin
const curlExample = `curl ${origin}/v1/chat/completions \\
-H "Authorization: Bearer sk-xxxx" \\
-H "Content-Type: application/json" \\
-d '{"model":"gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}'`
const balance = computed(() => state.user?.balance ?? '—')
const chartOption = computed<ECOption>(() => {
effectiveTheme.value // repaint when the theme switches
const rows = stats.value.slice().reverse()
return {
tooltip: {
trigger: 'axis',
backgroundColor: themeColor('--t-surface-2'),
borderColor: themeColor('--t-border-strong'),
textStyle: { color: themeColor('--t-text') },
},
legend: { data: ['请求数', '成本'], textStyle: { color: themeColor('--t-text-mute') }, top: 0 },
grid: { left: 8, right: 8, top: 32, bottom: 0, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.key),
axisLine: { lineStyle: { color: themeColor('--t-border') } },
axisLabel: { color: themeColor('--t-text-mute') },
},
yAxis: [
{
type: 'value',
splitLine: { lineStyle: { color: themeColor('--t-border', 0.5) } },
axisLabel: { color: themeColor('--t-text-mute') },
},
{
type: 'value',
splitLine: { show: false },
axisLabel: { color: themeColor('--t-text-mute'), formatter: '{value} $' },
},
],
series: [
{
name: '请求数',
type: 'bar',
barMaxWidth: 24,
itemStyle: { color: themeColor('--t-accent', 0.85), borderRadius: [3, 3, 0, 0] },
data: rows.map((r) => r.requests),
},
{
name: '成本',
type: 'line',
yAxisIndex: 1,
smooth: true,
symbol: 'none',
lineStyle: { color: themeColor('--t-success') },
itemStyle: { color: themeColor('--t-success') },
data: rows.map((r) => Number(r.cost)),
},
],
}
})
onMounted(async () => {
try {
const [s, st, keys] = await Promise.all([
usageApi.summary(),
usageApi.stats({ group: 'day' }),
keysApi.list(),
])
summary.value = s
stats.value = st
const active = keys.find((k) => k.status === 'active')
hasKey.value = !!active
} finally {
loading.value = false
}
})
function fmtTokens(n: number): string {
return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n)
}
</script>
<template>
<div>
<PageHeader title="仪表盘" desc="账户余额与用量概览" />
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat label="账户余额" :value="balance" tone="ok" mono hint="单位:美元" />
<Stat label="今日请求" :value="summary ? String(summary.today.requests) : '—'" />
<Stat label="今日 Token" :value="summary ? fmtTokens(summary.today.inputTokens + summary.today.outputTokens) : '—'" mono />
<Stat label="今日成本" :value="summary ? `$${Number(summary.today.cost).toFixed(6)}` : '—'" mono tone="info" />
</div>
<div class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card title="本月用量" desc="月度累计" class="lg:col-span-1">
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-ink-mute">请求次数</span>
<span class="num text-ink">{{ summary?.month.requests ?? '—' }}</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">输入 Token</span>
<span class="num text-ink">{{ summary ? fmtTokens(summary.month.inputTokens) : '—' }}</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">输出 Token</span>
<span class="num text-ink">{{ summary ? fmtTokens(summary.month.outputTokens) : '—' }}</span>
</div>
<div class="flex justify-between">
<span class="text-ink-mute">月成本</span>
<span class="num text-info">{{ summary ? `$${Number(summary.month.cost).toFixed(6)}` : '—' }}</span>
</div>
</div>
</Card>
<Card title="近 7 日趋势" desc="请求数 / 成本" class="lg:col-span-2">
<div class="h-64">
<VChart v-if="stats.length" :option="chartOption" autoresize />
<div v-else-if="!loading" class="flex h-full items-center justify-center text-sm text-ink-mute">
暂无用量数据
</div>
</div>
</Card>
</div>
<div class="mt-4">
<Card title="快速开始" desc="通过兼容端点接入">
<div v-if="!hasKey" class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-ink-soft">你还没有 API 密钥,创建后即可开始调用。</p>
<RouterLink to="/keys" class="text-sm text-brand hover:text-brand-hover">创建密钥 →</RouterLink>
</div>
<div v-else class="space-y-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<Badge tone="info">Base URL</Badge>
<code class="mono rounded bg-surface-2 px-2 py-1 text-xs text-ink">{{ origin }}/v1</code>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<Badge tone="ok">兼容</Badge>
<code class="mono rounded bg-surface-2 px-2 py-1 text-xs text-ink">Anthropic Messages · OpenAI Chat Completions · OpenAI Responses API</code>
</div>
<pre class="mono overflow-x-auto whitespace-pre rounded border border-line bg-bg p-3 text-xs text-ink-soft">{{ curlExample }}</pre>
</div>
</Card>
</div>
</div>
</template>
+195
View File
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Table, { type TableColumn } from '@/components/ui/Table.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { keysApi } from '@/api'
import type { ApiKey } from '@/types'
import { toast } from '@/components/ui/toast'
const keys = ref<ApiKey[]>([])
const loading = ref(true)
const creating = ref(false)
const createOpen = ref(false)
const keyReveal = ref<string | null>(null)
const form = ref({
name: '',
allowedModels: '',
expiresAt: '',
quotaTokensPerDay: '',
quotaRequestsPerDay: '',
})
const formError = ref('')
const columns: TableColumn[] = [
{ key: 'name', label: '名称' },
{ key: 'keyPrefix', label: '密钥前缀' },
{ key: 'status', label: '状态' },
{ key: 'quotaRequestsPerDay', label: '每日限额' },
{ key: 'expiresAt', label: '过期时间' },
{ key: 'lastUsedAt', label: '最近使用' },
{ key: 'actions', label: '操作', align: 'right' },
]
async function load() {
loading.value = true
try {
keys.value = await keysApi.list()
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
onMounted(load)
async function onCreate() {
formError.value = ''
if (!form.value.name.trim()) {
formError.value = '请填写密钥名称'
return
}
const models = form.value.allowedModels
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const qTokens = parseInt(form.value.quotaTokensPerDay, 10)
const qRequests = parseInt(form.value.quotaRequestsPerDay, 10)
creating.value = true
try {
const created = await keysApi.create({
name: form.value.name.trim(),
allowedModels: models.length ? models : undefined,
expiresAt: form.value.expiresAt ? new Date(form.value.expiresAt).toISOString() : undefined,
quotaRequestsPerDay: Number.isFinite(qRequests) && qRequests > 0 ? qRequests : undefined,
quotaTokensPerDay: Number.isFinite(qTokens) && qTokens > 0 ? qTokens : undefined,
})
keyReveal.value = created.key
form.value = { name: '', allowedModels: '', expiresAt: '', quotaTokensPerDay: '', quotaRequestsPerDay: '' }
createOpen.value = false
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '创建失败'
} finally {
creating.value = false
}
}
async function toggleStatus(k: ApiKey) {
try {
await keysApi.update(k.id, { status: k.status === 'active' ? 'revoked' : 'active' })
toast.success(k.status === 'active' ? '已吊销密钥' : '已恢复密钥')
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
}
}
async function onRemove(k: ApiKey) {
if (!window.confirm(`确定删除密钥「${k.name}」?该操作不可撤销。`)) return
try {
await keysApi.remove(k.id)
toast.success('已删除')
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : '删除失败')
}
}
async function onCopy(text: string) {
try {
await navigator.clipboard.writeText(text)
toast.success('已复制到剪贴板')
} catch {
toast.error('复制失败,请手动复制')
}
}
function fmtDate(s: string | null): string {
if (!s) return '—'
return new Date(s).toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<div>
<PageHeader title="API 密钥" desc="管理用于调用网关的密钥">
<template #actions>
<Button @click="createOpen = true">新建密钥</Button>
</template>
</PageHeader>
<Card padded>
<Table :columns="columns" :rows="keys" :loading="loading" empty-text="还没有密钥">
<template #col-name="{ row }">
<span class="font-medium text-ink">{{ row.name }}</span>
</template>
<template #col-keyPrefix="{ row }">
<code class="mono rounded bg-surface-2 px-1.5 py-0.5 text-xs text-ink-soft">{{ row.keyPrefix }}…</code>
</template>
<template #col-status="{ row }">
<Badge :tone="row.status === 'active' ? 'ok' : 'danger'">{{ row.status === 'active' ? '启用' : '已吊销' }}</Badge>
</template>
<template #col-quotaRequestsPerDay="{ row }">
<span class="num text-xs text-ink-soft">
{{ row.quotaRequestsPerDay ? `${row.quotaRequestsPerDay} req` : '不限' }}
</span>
</template>
<template #col-expiresAt="{ row }">
<span class="text-xs text-ink-soft">{{ fmtDate(row.expiresAt) }}</span>
</template>
<template #col-lastUsedAt="{ row }">
<span class="text-xs text-ink-soft">{{ fmtDate(row.lastUsedAt) }}</span>
</template>
<template #col-actions="{ row }">
<div class="flex justify-end gap-1">
<Button variant="ghost" size="xs" @click="onCopy(row.keyPrefix)">复制前缀</Button>
<Button variant="ghost" size="xs" @click="toggleStatus(row)">
{{ row.status === 'active' ? '吊销' : '恢复' }}
</Button>
<Button variant="danger" size="xs" @click="onRemove(row)">删除</Button>
</div>
</template>
</Table>
</Card>
<!-- create modal -->
<Modal :open="createOpen" title="新建密钥" width="max-w-md" @close="createOpen = false">
<form id="key-create-form" class="space-y-4" @submit.prevent="onCreate">
<Input v-model="form.name" label="名称" placeholder="例如:生产环境" autocomplete="off" />
<Input v-model="form.allowedModels" label="允许的模型" hint="逗号分隔的模型名,留空表示全部" placeholder="gpt-test, claude-test" />
<div class="grid grid-cols-2 gap-3">
<Input v-model="form.expiresAt" label="过期时间" type="datetime-local" />
<Input v-model="form.quotaTokensPerDay" label="每日 Token 限额" placeholder="留空不限" />
</div>
<Input v-model="form.quotaRequestsPerDay" label="每日请求限额" placeholder="留空不限" />
<p v-if="formError" class="text-sm text-danger">{{ formError }}</p>
</form>
<template #footer>
<Button variant="ghost" @click="createOpen = false">取消</Button>
<Button type="submit" form="key-create-form" :loading="creating">创建</Button>
</template>
</Modal>
<!-- reveal full key once -->
<Modal :open="!!keyReveal" title="密钥已创建" width="max-w-md" @close="keyReveal = null">
<p class="mb-3 text-sm text-warn">请立即复制保存,完整密钥仅显示一次。</p>
<div class="flex items-center gap-2 rounded border border-line bg-bg p-2">
<code class="mono flex-1 overflow-x-auto text-xs text-ink">{{ keyReveal }}</code>
<Button size="sm" @click="keyReveal && onCopy(keyReveal)">复制</Button>
</div>
<template #footer>
<Button @click="keyReveal = null">完成</Button>
</template>
</Modal>
<EmptyState v-if="!keys.length && !loading" title="还没有 API 密钥" desc="点击右上角「新建密钥」开始使用网关" />
</div>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import { useAuth } from '@/stores/auth'
const { state } = useAuth()
const origin = window.location.origin
const user = computed(() => state.user)
const balance = computed(() => state.user?.balance ?? '—')
function fmtDate(s: string | null): string {
if (!s) return '—'
return new Date(s).toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<div>
<PageHeader title="账户设置" desc="查看账户信息与配额" />
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="账户信息">
<dl class="space-y-3 text-sm">
<div class="flex justify-between">
<dt class="text-ink-mute">用户名</dt>
<dd class="text-ink">{{ user?.username }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-ink-mute">邮箱</dt>
<dd class="text-ink">{{ user?.email || '—' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-ink-mute">角色</dt>
<dd>
<Badge :tone="user?.role === 'admin' ? 'info' : 'neutral'">{{ user?.role === 'admin' ? '管理员' : '普通用户' }}</Badge>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-ink-mute">注册时间</dt>
<dd class="num text-ink">{{ fmtDate(user?.createdAt ?? null) }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-ink-mute">最近登录</dt>
<dd class="num text-ink">{{ fmtDate(user?.lastLoginAt ?? null) }}</dd>
</div>
</dl>
</Card>
<div class="space-y-4">
<Card title="账户余额">
<div class="flex items-end justify-between">
<p class="mono text-3xl font-bold text-ok">${{ balance }}</p>
<span class="text-xs text-ink-mute">余额不足时请求将无法下发</span>
</div>
</Card>
<Card title="接入信息">
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between gap-2">
<span class="text-ink-mute">Base URL</span>
<code class="mono rounded bg-surface-2 px-2 py-1 text-xs text-ink">{{ origin }}/v1</code>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-ink-mute">认证方式</span>
<code class="mono rounded bg-surface-2 px-2 py-1 text-xs text-ink">Authorization: Bearer sk-…</code>
</div>
<p class="text-xs text-ink-mute">密钥请到「API 密钥」页面创建;完整的协议参数与示例见控制台文档。</p>
</div>
</Card>
</div>
</div>
</div>
</template>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import VChart from 'vue-echarts'
import type { ComposeOption } from 'echarts/core'
import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts'
import type { GridComponentOption, TooltipComponentOption, LegendComponentOption } from 'echarts/components'
import PageHeader from '@/components/ui/PageHeader.vue'
import Card from '@/components/ui/Card.vue'
import Table, { type TableColumn } from '@/components/ui/Table.vue'
import Badge from '@/components/ui/Badge.vue'
import Input from '@/components/ui/Input.vue'
import Button from '@/components/ui/Button.vue'
import { usageApi } from '@/api'
import type { UsageLog, UsageStatRow, UsageSummary } from '@/types'
import { toast } from '@/components/ui/toast'
type ECOption = ComposeOption<
BarSeriesOption | LineSeriesOption | GridComponentOption | TooltipComponentOption | LegendComponentOption
>
const summary = ref<UsageSummary | null>(null)
const stats = ref<UsageStatRow[]>([])
const logs = ref<UsageLog[]>([])
const total = ref(0)
const loading = ref(true)
const from = ref('')
const to = ref('')
const model = ref('')
const page = ref(1)
const pageSize = 15
const columns: TableColumn[] = [
{ key: 'createdAt', label: '时间' },
{ key: 'model', label: '模型' },
{ key: 'requestId', label: 'Request ID' },
{ key: 'inputTokens', label: '输入' },
{ key: 'outputTokens', label: '输出' },
{ key: 'cost', label: '成本' },
{ key: 'latencyMs', label: '延迟' },
{ key: 'status', label: '状态' },
]
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const chartOption = computed<ECOption>(() => {
const rows = stats.value.slice().reverse()
return {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgb(var(--t-surface-2))',
borderColor: 'rgb(var(--t-border-strong))',
textStyle: { color: 'rgb(var(--t-text))' },
},
legend: { data: ['请求数', '成本'], textStyle: { color: 'rgb(var(--t-text-mute))' }, top: 0 },
grid: { left: 8, right: 8, top: 32, bottom: 0, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.key),
axisLine: { lineStyle: { color: 'rgb(var(--t-border))' } },
axisLabel: { color: 'rgb(var(--t-text-mute))' },
},
yAxis: [
{
type: 'value',
splitLine: { lineStyle: { color: 'rgb(var(--t-border) / 0.5)' } },
axisLabel: { color: 'rgb(var(--t-text-mute))' },
},
{
type: 'value',
splitLine: { show: false },
axisLabel: { color: 'rgb(var(--t-text-mute))', formatter: '{value} $' },
},
],
series: [
{
name: '请求数',
type: 'bar',
barMaxWidth: 24,
itemStyle: { color: 'rgb(var(--t-accent) / 0.85)', borderRadius: [3, 3, 0, 0] },
data: rows.map((r) => r.requests),
},
{
name: '成本',
type: 'line',
yAxisIndex: 1,
smooth: true,
symbol: 'none',
lineStyle: { color: 'rgb(var(--t-success))' },
data: rows.map((r) => Number(r.cost)),
},
],
}
})
async function load() {
loading.value = true
try {
const [s, st, lg] = await Promise.all([
usageApi.summary(),
usageApi.stats({
from: from.value || undefined,
to: to.value || undefined,
group: 'day',
}),
usageApi.logs({
page: page.value,
from: from.value || undefined,
to: to.value || undefined,
model: model.value || undefined,
}),
])
summary.value = s
stats.value = st
logs.value = lg.items
total.value = lg.total
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
load()
}
function go(p: number) {
if (p < 1 || p > totalPages.value) return
page.value = p
load()
}
onMounted(load)
function fmtDate(s: string): string {
return new Date(s).toLocaleString('zh-CN', { hour12: false })
}
function fmtNum(n: number): string {
return n.toLocaleString('en-US')
}
</script>
<template>
<div>
<PageHeader title="用量明细" desc="按时间查询请求、Token 与费用" />
<div class="mb-4 flex flex-wrap items-end gap-3">
<div class="w-40">
<Input v-model="from" label="开始日期" type="date" />
</div>
<div class="w-40">
<Input v-model="to" label="结束日期" type="date" />
</div>
<div class="w-48">
<Input v-model="model" label="模型" placeholder="全部模型" />
</div>
<Button @click="apply">查询</Button>
</div>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card padded>
<p class="text-xs text-ink-mute">今日请求</p>
<p class="num mt-1.5 text-xl font-semibold text-ink">{{ summary?.today.requests ?? '—' }}</p>
</Card>
<Card padded>
<p class="text-xs text-ink-mute">今日 Token</p>
<p class="num mt-1.5 text-xl font-semibold text-ink">
{{ summary ? (summary.today.inputTokens + summary.today.outputTokens).toLocaleString('en-US') : '—' }}
</p>
</Card>
<Card padded>
<p class="text-xs text-ink-mute">今日成本</p>
<p class="num mt-1.5 text-xl font-semibold text-info">${{ summary ? Number(summary.today.cost).toFixed(6) : '—' }}</p>
</Card>
<Card padded>
<p class="text-xs text-ink-mute">累计请求</p>
<p class="num mt-1.5 text-xl font-semibold text-ink">{{ summary?.total.requests ?? '—' }}</p>
</Card>
</div>
<Card title="趋势" class="mt-4">
<div class="h-64">
<VChart v-if="stats.length" :option="chartOption" autoresize />
<div v-else-if="!loading" class="flex h-full items-center justify-center text-sm text-ink-mute">暂无数据</div>
</div>
</Card>
<Card title="请求日志" class="mt-4">
<Table :columns="columns" :rows="logs" :loading="loading" empty-text="该条件下没有请求记录">
<template #col-createdAt="{ row }">
<span class="text-xs text-ink-soft">{{ fmtDate(row.createdAt) }}</span>
</template>
<template #col-model="{ row }">
<code class="mono text-xs text-ink">{{ row.model }}</code>
</template>
<template #col-requestId="{ row }">
<code class="mono text-xs text-ink-mute">{{ row.requestId }}</code>
</template>
<template #col-inputTokens="{ row }">
<span class="num text-xs text-ink-soft">{{ fmtNum(row.inputTokens) }}</span>
</template>
<template #col-outputTokens="{ row }">
<span class="num text-xs text-ink-soft">{{ fmtNum(row.outputTokens) }}</span>
</template>
<template #col-cost="{ row }">
<span class="num text-xs text-info">${{ Number(row.cost).toFixed(6) }}</span>
</template>
<template #col-latencyMs="{ row }">
<span class="num text-xs text-ink-soft">{{ row.latencyMs }} ms</span>
</template>
<template #col-status="{ row }">
<Badge :tone="row.status === 'success' ? 'ok' : 'danger'">
{{ row.status === 'success' ? '成功' : row.errorCode || '失败' }}
</Badge>
</template>
</Table>
<div v-if="total > pageSize" class="mt-4 flex items-center justify-between">
<span class="text-xs text-ink-mute">共 {{ total }} 条</span>
<div class="flex items-center gap-2">
<Button variant="outline" size="xs" :disabled="page <= 1" @click="go(page - 1)">上一页</Button>
<span class="num text-xs text-ink-soft">{{ page }} / {{ totalPages }}</span>
<Button variant="outline" size="xs" :disabled="page >= totalPages" @click="go(page + 1)">下一页</Button>
</div>
</div>
</Card>
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+45
View File
@@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts,tsx}'],
theme: {
extend: {
colors: {
bg: 'rgb(var(--t-bg) / <alpha-value>)',
surface: {
DEFAULT: 'rgb(var(--t-surface) / <alpha-value>)',
2: 'rgb(var(--t-surface-2) / <alpha-value>)',
},
line: {
DEFAULT: 'rgb(var(--t-border) / <alpha-value>)',
strong: 'rgb(var(--t-border-strong) / <alpha-value>)',
},
ink: {
DEFAULT: 'rgb(var(--t-text) / <alpha-value>)',
soft: 'rgb(var(--t-text-soft) / <alpha-value>)',
mute: 'rgb(var(--t-text-mute) / <alpha-value>)',
},
brand: {
DEFAULT: 'rgb(var(--t-accent) / <alpha-value>)',
hover: 'rgb(var(--t-accent-hover) / <alpha-value>)',
},
ok: 'rgb(var(--t-success) / <alpha-value>)',
warn: 'rgb(var(--t-warning) / <alpha-value>)',
danger: 'rgb(var(--t-danger) / <alpha-value>)',
info: 'rgb(var(--t-info) / <alpha-value>)',
},
fontFamily: {
sans: 'var(--t-font-sans)',
mono: 'var(--t-font-mono)',
},
borderRadius: {
DEFAULT: 'var(--t-radius)',
sm: 'var(--t-radius-sm)',
},
boxShadow: {
card: 'var(--t-shadow-card)',
pop: 'var(--t-shadow-pop)',
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// Dev: proxy /api and /v1 to the Go backend so the browser stays same-origin
// (no CORS, and the HttpOnly refresh cookie flows through the proxy).
const backend = process.env.VITE_PROXY_TARGET || 'http://localhost:18080'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': { target: backend, changeOrigin: true },
'/v1': { target: backend, changeOrigin: true },
},
},
preview: {
host: true, // bind 0.0.0.0 so the built SPA is reachable over the public IP
port: 5173,
proxy: {
'/api': { target: backend, changeOrigin: true },
'/v1': { target: backend, changeOrigin: true },
},
},
})