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:
co-authored by
Claude Sonnet 5
parent
d0e31b198f
commit
489a79564c
@@ -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>
|
||||
Generated
+2641
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`),
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user