前端: 浅色模式 + 系统自动主题
- 引入语义化 CSS 变量令牌(bg/surface/edge/ink/muted/accent 等), data-theme 三态切换 - theme store(localStorage + 系统偏好跟随), 首屏内联脚本防主题闪烁 - ThemeToggle 组件(浅色/深色/跟随系统) 置于导航与控制台顶栏 - 全量替换硬编码 zinc/emerald/red 类为令牌, 图表基线/状态色随主题适配 - 浅色对比度校准(accent/err/warn 深浅各一套) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+13
-2
@@ -1,10 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<script>
|
||||
// 防止主题闪烁:应用前先按 localStorage/系统偏好设置 data-theme
|
||||
;(function () {
|
||||
try {
|
||||
var m = localStorage.getItem('ot_theme') || 'system'
|
||||
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<title>openteam · LLM API 中转站</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { PhList } from '@phosphor-icons/vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fmtMoney } from '@/lib/format'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
@@ -27,7 +28,7 @@ async function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] bg-zinc-950">
|
||||
<div class="flex min-h-[100dvh] bg-bg">
|
||||
<!-- 移动端遮罩 -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
@@ -37,25 +38,25 @@ async function logout() {
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-56 transform flex-col border-r border-zinc-800/80 bg-zinc-950 transition-transform duration-200 md:translate-x-0"
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-56 transform flex-col border-r border-edge/80 bg-bg transition-transform duration-200 md:translate-x-0"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex h-14 items-center gap-2 border-b border-zinc-800/80 px-4">
|
||||
<div class="flex h-14 items-center gap-2 border-b border-edge/80 px-4">
|
||||
<img src="/favicon.svg" alt="" class="size-5" />
|
||||
<span class="text-sm font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<template v-for="sec in sections" :key="sec.title">
|
||||
<p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-zinc-600 uppercase first:mt-0">
|
||||
<p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-muted uppercase first:mt-0">
|
||||
{{ sec.title }}
|
||||
</p>
|
||||
<router-link
|
||||
v-for="n in sec.items"
|
||||
:key="n.to"
|
||||
:to="n.to"
|
||||
class="mb-0.5 flex items-center rounded-md px-2 py-1.5 text-sm text-zinc-400 transition hover:bg-zinc-800/50 hover:text-zinc-100"
|
||||
active-class="bg-zinc-800/70 text-zinc-50"
|
||||
class="mb-0.5 flex items-center rounded-md px-2 py-1.5 text-sm text-muted transition hover:bg-surface2/50 hover:text-ink"
|
||||
active-class="bg-surface2/70 text-zinc-50"
|
||||
@click="navTo"
|
||||
>
|
||||
{{ n.label }}
|
||||
@@ -65,23 +66,24 @@ async function logout() {
|
||||
</aside>
|
||||
|
||||
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
|
||||
<header class="flex h-14 items-center justify-between border-b border-zinc-800/80 px-4 md:px-6">
|
||||
<header class="flex h-14 items-center justify-between border-b border-edge/80 px-4 md:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 md:hidden"
|
||||
class="rounded-md p-1.5 text-muted hover:bg-surface2 hover:text-ink md:hidden"
|
||||
aria-label="打开菜单"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<PhList :size="20" />
|
||||
</button>
|
||||
<span class="font-mono text-xs text-zinc-600">{{ auth.user?.username }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ auth.user?.username }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mono-num rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1 text-xs text-emerald-300">
|
||||
<span class="mono-num rounded-md border border-edge bg-surface px-2.5 py-1 text-xs text-accent">
|
||||
余额 {{ balance }}
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
<button
|
||||
class="rounded-md px-2 py-1 text-xs text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
class="rounded-md px-2 py-1 text-xs text-muted transition hover:bg-surface2 hover:text-ink"
|
||||
@click="logout"
|
||||
>
|
||||
退出
|
||||
|
||||
@@ -8,11 +8,11 @@ withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-mono text-[11px] leading-5"
|
||||
:class="{
|
||||
neutral: 'bg-zinc-800/80 text-zinc-300',
|
||||
ok: 'bg-ok/15 text-emerald-300',
|
||||
warn: 'bg-warn/15 text-amber-300',
|
||||
err: 'bg-err/15 text-red-300',
|
||||
accent: 'bg-accent/15 text-emerald-300',
|
||||
neutral: 'bg-surface2/80 text-ink',
|
||||
ok: 'bg-ok/15 text-accent',
|
||||
warn: 'bg-warn/15 text-warn',
|
||||
err: 'bg-err/15 text-err',
|
||||
accent: 'bg-accent/15 text-accent',
|
||||
}[variant]"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -16,9 +16,9 @@ withDefaults(
|
||||
class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none"
|
||||
:class="[
|
||||
size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm',
|
||||
variant === 'primary' && 'bg-accent text-zinc-950 hover:bg-accent-strong',
|
||||
variant === 'ghost' && 'border border-zinc-700 text-zinc-200 hover:border-zinc-500 hover:bg-zinc-800/60',
|
||||
variant === 'danger' && 'border border-err/50 text-red-300 hover:border-err hover:bg-err/10',
|
||||
variant === 'primary' && 'bg-accent text-accent-ink hover:bg-accent-strong',
|
||||
variant === 'ghost' && 'border border-edge2 text-ink hover:border-edge2 hover:bg-surface2/60',
|
||||
variant === 'danger' && 'border border-err/50 text-err hover:border-err hover:bg-err/10',
|
||||
]"
|
||||
>
|
||||
<span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
|
||||
@@ -17,18 +17,18 @@ const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
|
||||
<template>
|
||||
<label class="block">
|
||||
<span v-if="label" class="mb-1.5 block text-xs font-medium text-zinc-400">{{ label }}</span>
|
||||
<span v-if="label" class="mb-1.5 block text-xs font-medium text-muted">{{ label }}</span>
|
||||
<input
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:disabled="disabled"
|
||||
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink placeholder-muted outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="error && 'border-err focus:border-err focus:ring-err/30'"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
|
||||
/>
|
||||
<span v-if="hint && !error" class="mt-1.5 block text-xs text-zinc-500">{{ hint }}</span>
|
||||
<span v-if="error" class="mt-1.5 block text-xs text-red-400">{{ error }}</span>
|
||||
<span v-if="hint && !error" class="mt-1.5 block text-xs text-muted">{{ hint }}</span>
|
||||
<span v-if="error" class="mt-1.5 block text-xs text-err">{{ error }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -65,16 +65,16 @@ onUnmounted(() => {
|
||||
class="card w-full shadow-xl outline-none"
|
||||
:class="width"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5">
|
||||
<h3 class="text-sm font-semibold text-zinc-100">{{ title }}</h3>
|
||||
<button class="rounded-md p-1 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-200" aria-label="关闭" @click="emit('close')">
|
||||
<div class="flex items-center justify-between border-b border-edge px-5 py-3.5">
|
||||
<h3 class="text-sm font-semibold text-ink">{{ title }}</h3>
|
||||
<button class="rounded-md p-1 text-muted hover:bg-surface2 hover:text-ink" aria-label="关闭" @click="emit('close')">
|
||||
<PhX :size="16" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-zinc-800 px-5 py-3.5">
|
||||
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ withDefaults(
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="animate-pulse bg-zinc-800/60"
|
||||
class="animate-pulse bg-surface2/60"
|
||||
:class="[rounded, cls]"
|
||||
:style="{ height, width }"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { PhSun, PhMoon, PhMonitor } from '@phosphor-icons/vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const theme = useThemeStore()
|
||||
const icon = computed(() => {
|
||||
switch (theme.resolved) {
|
||||
case 'light':
|
||||
return PhSun
|
||||
case 'dark':
|
||||
return PhMoon
|
||||
default:
|
||||
return PhMonitor
|
||||
}
|
||||
})
|
||||
const label = computed(() => {
|
||||
const names = { light: '浅色', dark: '深色', system: '跟随系统' }
|
||||
return names[theme.mode]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-muted transition hover:bg-surface2 hover:text-ink"
|
||||
:title="`主题:${label},点击切换`"
|
||||
:aria-label="`主题:${label},点击切换`"
|
||||
@click="theme.cycle()"
|
||||
>
|
||||
<component :is="icon" :size="15" weight="bold" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -19,9 +19,9 @@ const toast = useToastStore()
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 size-1.5 shrink-0 rounded-full"
|
||||
:class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-zinc-500'"
|
||||
:class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-muted'"
|
||||
/>
|
||||
<p class="text-sm text-zinc-200">{{ t.msg }}</p>
|
||||
<p class="text-sm text-ink">{{ t.msg }}</p>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ const chart = computed(() => {
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="mb-1 flex items-baseline justify-between">
|
||||
<span class="mono-num text-xs text-zinc-500">max {{ chart.maxLabel }}</span>
|
||||
<span class="mono-num text-xs text-muted">max {{ chart.maxLabel }}</span>
|
||||
</div>
|
||||
<svg
|
||||
:viewBox="`0 0 100 ${height}`"
|
||||
@@ -38,7 +38,7 @@ const chart = computed(() => {
|
||||
:aria-label="`用量趋势,共 ${points.length} 天`"
|
||||
>
|
||||
<!-- 基线 -->
|
||||
<line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="rgb(39 39 42)" stroke-width="0.6" />
|
||||
<line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="var(--edge)" stroke-width="0.6" />
|
||||
<g v-for="b in chart.bars" :key="b.label">
|
||||
<rect
|
||||
:x="b.x"
|
||||
@@ -50,7 +50,7 @@ const chart = computed(() => {
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="mt-1 flex justify-between font-mono text-[10px] text-zinc-600">
|
||||
<div class="mt-1 flex justify-between font-mono text-[10px] text-muted">
|
||||
<span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -2,12 +2,12 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import './style.css'
|
||||
|
||||
// 深色优先(MVP 固定深色,后续加亮色切换)
|
||||
document.documentElement.classList.add('dark')
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
useThemeStore(pinia).init()
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
const STORAGE_KEY = 'ot_theme'
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function resolve(mode: ThemeMode): 'light' | 'dark' {
|
||||
if (mode === 'system') return media.matches ? 'dark' : 'light'
|
||||
return mode
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', {
|
||||
state: () => ({
|
||||
mode: (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system',
|
||||
}),
|
||||
getters: {
|
||||
resolved(state): 'light' | 'dark' {
|
||||
return resolve(state.mode)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
// 应用当前主题;监听系统切换(system 模式时跟随)
|
||||
init() {
|
||||
this.apply()
|
||||
media.addEventListener('change', () => this.apply())
|
||||
},
|
||||
set(mode: ThemeMode) {
|
||||
this.mode = mode
|
||||
localStorage.setItem(STORAGE_KEY, mode)
|
||||
this.apply()
|
||||
},
|
||||
cycle() {
|
||||
const order: ThemeMode[] = ['system', 'light', 'dark']
|
||||
const i = order.indexOf(this.mode)
|
||||
this.set(order[(i + 1) % order.length])
|
||||
},
|
||||
apply() {
|
||||
document.documentElement.setAttribute('data-theme', this.resolved)
|
||||
},
|
||||
},
|
||||
})
|
||||
+68
-30
@@ -4,43 +4,96 @@
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 设计 tokens(taste-skill 产出) */
|
||||
/* 深色优先 · 单一强调色 emerald · 圆角体系:卡片 8 / 控件 6 / 徽章 pill */
|
||||
/* 密度 7:mono 数字、紧凑表格、细线分隔 */
|
||||
/* 三主题:dark / light / system(data-theme 切换,CSS 变量语义令牌) */
|
||||
/* 单一强调色 emerald · 圆角:卡片 8 / 控件 6 / 徽章 pill */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
/* 强调色(单一一处定义,全局一致) */
|
||||
--color-accent: oklch(0.72 0.17 152);
|
||||
--color-accent-strong: oklch(0.64 0.19 152);
|
||||
--color-accent-soft: oklch(0.95 0.05 152);
|
||||
|
||||
/* 状态色(语义,克制使用) */
|
||||
--color-ok: oklch(0.72 0.17 152);
|
||||
--color-warn: oklch(0.80 0.15 75);
|
||||
--color-err: oklch(0.63 0.21 25);
|
||||
/* 语义令牌 → CSS 变量(随 data-theme 切换) */
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface2: var(--surface2);
|
||||
--color-edge: var(--edge);
|
||||
--color-edge2: var(--edge2);
|
||||
--color-ink: var(--ink);
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-strong: var(--accent-strong);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-ok: var(--ok);
|
||||
--color-warn: var(--warn);
|
||||
--color-err: var(--err);
|
||||
}
|
||||
|
||||
html {
|
||||
/* 深色(默认) */
|
||||
:root,
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--bg: #09090b;
|
||||
--surface: #18181b;
|
||||
--surface2: #27272a;
|
||||
--edge: #27272a;
|
||||
--edge2: #3f3f46;
|
||||
--ink: #f4f4f5;
|
||||
--muted: #a1a1aa;
|
||||
--accent: oklch(0.72 0.17 152);
|
||||
--accent-strong: oklch(0.64 0.19 152);
|
||||
--accent-ink: #09090b;
|
||||
--ok: oklch(0.72 0.17 152);
|
||||
--warn: #fbbf24;
|
||||
--err: #f87171;
|
||||
}
|
||||
|
||||
/* 浅色 */
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--surface2: #f4f4f5;
|
||||
--edge: #e4e4e7;
|
||||
--edge2: #d4d4d8;
|
||||
--ink: #18181b;
|
||||
--muted: #52525b;
|
||||
--accent: oklch(0.58 0.16 152);
|
||||
--accent-strong: oklch(0.52 0.16 152);
|
||||
--accent-ink: #ffffff;
|
||||
--ok: oklch(0.58 0.16 152);
|
||||
--warn: #d97706;
|
||||
--err: #dc2626;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #09090b;
|
||||
color: #f4f4f5;
|
||||
background-color: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 全站统一键盘焦点可见性 */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 通用组件视觉基元 */
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-lg border border-edge bg-surface;
|
||||
}
|
||||
|
||||
.mono-num {
|
||||
@apply font-mono tabular-nums;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply border-b border-edge last:border-0 hover:bg-surface2/40;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -49,18 +102,3 @@ body {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用组件视觉基元 */
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-lg border border-zinc-800 bg-zinc-900/60;
|
||||
}
|
||||
|
||||
.mono-num {
|
||||
@apply font-mono tabular-nums;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply border-b border-zinc-800/70 last:border-0 hover:bg-zinc-800/30;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[100dvh] bg-zinc-950 text-zinc-100">
|
||||
<div class="min-h-[100dvh] bg-bg text-ink">
|
||||
<!-- 导航 -->
|
||||
<header class="sticky top-0 z-40 border-b border-zinc-800/60 bg-zinc-950/80 backdrop-blur">
|
||||
<header class="sticky top-0 z-40 border-b border-edge/60 bg-bg/80 backdrop-blur">
|
||||
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-5" />
|
||||
<span class="text-sm font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<router-link
|
||||
v-if="!auth.isAuthed"
|
||||
to="/login"
|
||||
class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100"
|
||||
class="rounded-md px-3 py-1.5 text-sm text-muted transition hover:text-ink"
|
||||
>
|
||||
登录
|
||||
</router-link>
|
||||
<router-link v-else to="/console/dashboard" class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100">
|
||||
<router-link v-else to="/console/dashboard" class="rounded-md px-3 py-1.5 text-sm text-muted transition hover:text-ink">
|
||||
控制台
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="!auth.isAuthed"
|
||||
to="/register"
|
||||
class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong"
|
||||
class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-ink transition hover:bg-accent-strong"
|
||||
>
|
||||
免费注册
|
||||
</router-link>
|
||||
@@ -39,49 +41,49 @@ const auth = useAuthStore()
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" />
|
||||
<div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24">
|
||||
<div class="max-w-xl">
|
||||
<p class="mb-3 font-mono text-xs tracking-wide text-emerald-400/80">LLM API 中转网关</p>
|
||||
<p class="mb-3 font-mono text-xs tracking-wide text-accent">LLM API 中转网关</p>
|
||||
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl">
|
||||
一个 Key,调用所有主流模型
|
||||
</h1>
|
||||
<p class="mt-5 max-w-md text-base leading-relaxed text-zinc-400">
|
||||
<p class="mt-5 max-w-md text-base leading-relaxed text-muted">
|
||||
统一 OpenAI 与 Anthropic 协议入口,三套 API 自动互转,用量、计费与 API Key 管理开箱即用。
|
||||
</p>
|
||||
<div class="mt-8 flex items-center gap-3">
|
||||
<router-link
|
||||
to="/register"
|
||||
class="inline-flex h-10 items-center rounded-md bg-accent px-5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]"
|
||||
class="inline-flex h-10 items-center rounded-md bg-accent px-5 text-sm font-medium text-ink transition hover:bg-accent-strong active:scale-[0.98]"
|
||||
>
|
||||
免费注册
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/login"
|
||||
class="inline-flex h-10 items-center rounded-md border border-zinc-700 px-5 text-sm text-zinc-200 transition hover:border-zinc-500"
|
||||
class="inline-flex h-10 items-center rounded-md border border-edge2 px-5 text-sm text-ink transition hover:border-edge2"
|
||||
>
|
||||
登录
|
||||
</router-link>
|
||||
</div>
|
||||
<p class="mt-5 font-mono text-xs text-zinc-600">不用换 SDK,改一行 base_url 即可接入</p>
|
||||
<p class="mt-5 font-mono text-xs text-muted">不用换 SDK,改一行 base_url 即可接入</p>
|
||||
</div>
|
||||
|
||||
<!-- 调用演示(真实格式,非伪截图) -->
|
||||
<div class="card overflow-hidden font-mono text-xs">
|
||||
<div class="flex items-center gap-1.5 border-b border-zinc-800 px-4 py-2.5">
|
||||
<span class="size-2.5 rounded-full bg-zinc-700" />
|
||||
<span class="size-2.5 rounded-full bg-zinc-700" />
|
||||
<span class="ml-2 text-zinc-500">curl api.openteam.dev</span>
|
||||
<div class="flex items-center gap-1.5 border-b border-edge px-4 py-2.5">
|
||||
<span class="size-2.5 rounded-full bg-surface2" />
|
||||
<span class="size-2.5 rounded-full bg-surface2" />
|
||||
<span class="ml-2 text-muted">curl api.openteam.dev</span>
|
||||
</div>
|
||||
<div class="space-y-3 p-4 leading-relaxed">
|
||||
<div>
|
||||
<p class="text-zinc-400"><span class="text-emerald-400">$</span> curl https://api.openteam.dev/v1/chat/completions</p>
|
||||
<p class="text-zinc-400"> -H <span class="text-emerald-300">"Authorization: Bearer sk-..."</span> \</p>
|
||||
<p class="text-zinc-400"> -d <span class="text-zinc-300">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
|
||||
<p class="text-muted"><span class="text-accent">$</span> curl https://api.openteam.dev/v1/chat/completions</p>
|
||||
<p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-..."</span> \</p>
|
||||
<p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
|
||||
</div>
|
||||
<div class="border-t border-zinc-800 pt-3 text-zinc-500">
|
||||
<p class="text-zinc-600"># OpenAI 格式请求,网关自动转 Anthropic 协议</p>
|
||||
<p class="text-zinc-300">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p>
|
||||
<p class="text-zinc-300"> "delta":{"content":"你好,这是流式回复"}</p>
|
||||
<p class="text-zinc-300">}]}</p>
|
||||
<p class="text-zinc-300">data: [DONE]</p>
|
||||
<div class="border-t border-edge pt-3 text-muted">
|
||||
<p class="text-muted"># OpenAI 格式请求,网关自动转 Anthropic 协议</p>
|
||||
<p class="text-ink">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p>
|
||||
<p class="text-ink"> "delta":{"content":"你好,这是流式回复"}</p>
|
||||
<p class="text-ink">}]}</p>
|
||||
<p class="text-ink">data: [DONE]</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,57 +91,57 @@ const auth = useAuthStore()
|
||||
</section>
|
||||
|
||||
<!-- 协议入口 -->
|
||||
<section class="border-t border-zinc-800/60">
|
||||
<section class="border-t border-edge/60">
|
||||
<div class="mx-auto max-w-6xl px-4 py-16">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">三套协议,一个入口</h2>
|
||||
<p class="mt-2 max-w-xl text-sm text-zinc-500">
|
||||
<p class="mt-2 max-w-xl text-sm text-muted">
|
||||
对客户端暴露统一的 OpenAI 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。
|
||||
</p>
|
||||
<div class="card mt-8 divide-y divide-zinc-800/70">
|
||||
<div class="card mt-8 divide-y divide-edge/70">
|
||||
<div v-for="p in [
|
||||
{ path: 'POST /v1/chat/completions', desc: 'OpenAI Chat,兼容面最广,SDK 与工具链最全', tag: 'OpenAI' },
|
||||
{ path: 'POST /v1/responses', desc: 'OpenAI Responses,新一代 SDK 与 Agents 首选', tag: 'OpenAI' },
|
||||
{ path: 'POST /v1/messages', desc: 'Anthropic Messages,Claude 生态原生格式', tag: 'Anthropic' },
|
||||
{ path: 'GET /v1/models', desc: 'OpenAI 风格模型列表', tag: 'List' },
|
||||
]" :key="p.path" class="grid gap-1 px-5 py-3.5 sm:grid-cols-3 sm:items-center">
|
||||
<code class="font-mono text-sm text-emerald-300">{{ p.path }}</code>
|
||||
<p class="text-sm text-zinc-400 sm:col-span-1">{{ p.desc }}</p>
|
||||
<span class="hidden justify-self-end font-mono text-[11px] text-zinc-600 sm:block">{{ p.tag }}</span>
|
||||
<code class="font-mono text-sm text-accent">{{ p.path }}</code>
|
||||
<p class="text-sm text-muted sm:col-span-1">{{ p.desc }}</p>
|
||||
<span class="hidden justify-self-end font-mono text-[11px] text-muted sm:block">{{ p.tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 网关能力 -->
|
||||
<section class="border-t border-zinc-800/60">
|
||||
<section class="border-t border-edge/60">
|
||||
<div class="mx-auto max-w-6xl px-4 py-16">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">网关替你处理的事</h2>
|
||||
<div class="mt-8 grid gap-4 lg:grid-cols-3">
|
||||
<div class="card p-5 lg:col-span-2">
|
||||
<p class="font-mono text-xs text-emerald-400/80">responses → claude-sonnet-5</p>
|
||||
<p class="font-mono text-xs text-accent">responses → claude-sonnet-5</p>
|
||||
<h3 class="mt-2 text-lg font-semibold">协议自动转换</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="font-mono text-xs text-zinc-600">usage · daily</p>
|
||||
<p class="font-mono text-xs text-muted">usage · daily</p>
|
||||
<h3 class="mt-2 text-lg font-semibold">用量与计费</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="font-mono text-xs text-zinc-600">sk-…</p>
|
||||
<p class="font-mono text-xs text-muted">sk-…</p>
|
||||
<h3 class="mt-2 text-lg font-semibold">API Key 管理</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5 lg:col-span-2">
|
||||
<p class="font-mono text-xs text-zinc-600">channels · lb · health</p>
|
||||
<p class="font-mono text-xs text-muted">channels · lb · health</p>
|
||||
<h3 class="mt-2 text-lg font-semibold">多渠道接入</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
一个模型绑定多个上游渠道,按权重与健康状态选择,故障自动转移。管理员在后台一键接入新渠道、导入模型并定价。
|
||||
</p>
|
||||
</div>
|
||||
@@ -148,21 +150,21 @@ const auth = useAuthStore()
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="border-t border-zinc-800/60">
|
||||
<section class="border-t border-edge/60">
|
||||
<div class="mx-auto max-w-6xl px-4 py-20 text-center">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">自托管,密钥在自己手里</h2>
|
||||
<p class="mx-auto mt-3 max-w-md text-sm text-zinc-500">Docker Compose 一键部署,PostgreSQL 存账,渠道密钥加密存储。</p>
|
||||
<p class="mx-auto mt-3 max-w-md text-sm text-muted">Docker Compose 一键部署,PostgreSQL 存账,渠道密钥加密存储。</p>
|
||||
<router-link
|
||||
to="/register"
|
||||
class="mt-8 inline-flex h-10 items-center rounded-md bg-accent px-6 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]"
|
||||
class="mt-8 inline-flex h-10 items-center rounded-md bg-accent px-6 text-sm font-medium text-ink transition hover:bg-accent-strong active:scale-[0.98]"
|
||||
>
|
||||
开始使用
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-zinc-800/60">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-zinc-600">
|
||||
<footer class="border-t border-edge/60">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-muted">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-4" />
|
||||
<span>openteam · LLM API 中转站</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToastStore } from '@/stores/toast'
|
||||
import { errMsg } from '@/api/client'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -35,24 +36,25 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4">
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-bg px-4">
|
||||
<ThemeToggle class="fixed right-4 top-4" />
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mb-3 inline-flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-7" />
|
||||
<span class="text-lg font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-500">登录到控制台</p>
|
||||
<p class="text-sm text-muted">登录到控制台</p>
|
||||
</div>
|
||||
|
||||
<form class="card space-y-4 p-6" @submit.prevent="submit">
|
||||
<Input v-model="username" label="用户名或邮箱" autocomplete="username" placeholder="alice" />
|
||||
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
<p v-if="error" class="text-xs text-err">{{ error }}</p>
|
||||
<Button class="w-full" :loading="loading" type="submit">登录</Button>
|
||||
</form>
|
||||
|
||||
<p class="mt-5 text-center text-sm text-zinc-500">
|
||||
<p class="mt-5 text-center text-sm text-muted">
|
||||
还没有账号?
|
||||
<router-link to="/register" class="text-accent hover:text-accent-strong">注册</router-link>
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToastStore } from '@/stores/toast'
|
||||
import { errMsg } from '@/api/client'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -42,14 +43,15 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4">
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-bg px-4">
|
||||
<ThemeToggle class="fixed right-4 top-4" />
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mb-3 inline-flex items-center justify-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-7" />
|
||||
<span class="text-lg font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-500">一个 Key 访问多家模型</p>
|
||||
<p class="text-sm text-muted">一个 Key 访问多家模型</p>
|
||||
</div>
|
||||
|
||||
<form class="card space-y-4 p-6" @submit.prevent="submit">
|
||||
@@ -62,11 +64,11 @@ async function submit() {
|
||||
autocomplete="new-password"
|
||||
hint="至少 8 位"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
<p v-if="error" class="text-xs text-err">{{ error }}</p>
|
||||
<Button class="w-full" :loading="loading" type="submit">注册</Button>
|
||||
</form>
|
||||
|
||||
<p class="mt-5 text-center text-sm text-zinc-500">
|
||||
<p class="mt-5 text-center text-sm text-muted">
|
||||
已有账号?
|
||||
<router-link to="/login" class="text-accent hover:text-accent-strong">登录</router-link>
|
||||
</p>
|
||||
|
||||
@@ -131,7 +131,7 @@ onMounted(load)
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">渠道</h1>
|
||||
<p class="text-sm text-zinc-500">接入上游服务,API Key 加密存储</p>
|
||||
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
|
||||
</div>
|
||||
<Button @click="openCreate">添加渠道</Button>
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@ onMounted(load)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">类型</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
|
||||
@@ -152,29 +152,29 @@ onMounted(load)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ch in channels" :key="ch.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-zinc-200">{{ ch.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ providerMap[ch.provider] }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ ch.base_url }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600">{{ ch.api_key_masked || '****' }}</td>
|
||||
<td class="px-4 py-2.5 text-ink">{{ ch.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ providerMap[ch.provider] }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.api_key_masked || '****' }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||||
{{ ch.health_status }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ ch.enabled ? '是' : '否' }}</td>
|
||||
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<button class="text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" @click="importModels(ch)">导入模型</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(ch)">编辑</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-red-400" @click="remove(ch)">删除</button>
|
||||
<button class="text-xs text-muted hover:text-accent" @click="importModels(ch)">导入模型</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(ch)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="remove(ch)">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="channels.length === 0">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">还没有渠道,点击「添加渠道」</td>
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -186,8 +186,8 @@ onMounted(load)
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input v-model="form.name" label="名称" placeholder="openai" />
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">API 类型</span>
|
||||
<select v-model="form.provider" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">API 类型</span>
|
||||
<select v-model="form.provider" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="compatible">兼容</option>
|
||||
|
||||
@@ -41,49 +41,49 @@ onMounted(load)
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-lg font-semibold">系统配置</h1>
|
||||
<p class="text-sm text-zinc-500">注册策略等平台级配置</p>
|
||||
<p class="text-sm text-muted">注册策略等平台级配置</p>
|
||||
</div>
|
||||
|
||||
<div class="card space-y-5 p-6">
|
||||
<div v-if="loading" class="py-8 text-center text-sm text-zinc-600">加载中…</div>
|
||||
<div v-if="loading" class="py-8 text-center text-sm text-muted">加载中…</div>
|
||||
<template v-else>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium text-zinc-400">注册模式</p>
|
||||
<p class="mb-1.5 text-xs font-medium text-muted">注册模式</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
|
||||
:class="config.registration_mode === 'open' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
|
||||
:class="config.registration_mode === 'open' ? 'border-accent bg-accent/10 text-accent' : 'border-edge2 text-muted'"
|
||||
@click="config.registration_mode = 'open'"
|
||||
>
|
||||
开放注册
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
|
||||
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
|
||||
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent/10 text-accent' : 'border-edge2 text-muted'"
|
||||
@click="config.registration_mode = 'invite'"
|
||||
>
|
||||
邀请码
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-zinc-600">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
|
||||
<p class="mt-1.5 text-xs text-muted">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium text-zinc-400">邀请码(逗号分隔)</p>
|
||||
<p class="mb-1.5 text-xs font-medium text-muted">邀请码(逗号分隔)</p>
|
||||
<input
|
||||
v-model="config.invite_codes"
|
||||
placeholder="code1,code2"
|
||||
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs outline-none focus:border-accent"
|
||||
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900/60 px-4 py-3">
|
||||
<div class="flex items-center justify-between rounded-md border border-edge bg-surface/60 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm text-zinc-300">其他配置项</p>
|
||||
<p class="text-xs text-zinc-600">汇率、限流阈值、维护开关在后续里程碑开放</p>
|
||||
<p class="text-sm text-ink">其他配置项</p>
|
||||
<p class="text-xs text-muted">汇率、限流阈值、维护开关在后续里程碑开放</p>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-zinc-600">M3+</span>
|
||||
<span class="font-mono text-xs text-muted">M3+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ onMounted(load)
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">模型与定价</h1>
|
||||
<p class="text-sm text-zinc-500">价格按每百万 token (USD),历史用量按当时价格入账</p>
|
||||
<p class="text-sm text-muted">价格按每百万 token (USD),历史用量按当时价格入账</p>
|
||||
</div>
|
||||
<Button @click="openCreate">添加模型</Button>
|
||||
</div>
|
||||
@@ -139,39 +139,39 @@ onMounted(load)
|
||||
<div v-for="m in models" :key="m.id" class="card">
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-sm text-zinc-100">{{ m.name }}</span>
|
||||
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
||||
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mono-num text-xs text-zinc-400">入 {{ m.input_price }}</span>
|
||||
<span class="mono-num text-xs text-zinc-400">出 {{ m.output_price }}</span>
|
||||
<span class="mono-num text-xs text-zinc-500">缓存读 {{ m.cache_read_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBind(m)">绑定渠道</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(m)">编辑</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-red-400" @click="removeModel(m)">删除</button>
|
||||
<button class="text-xs text-muted hover:text-accent" @click="openBind(m)">绑定渠道</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="m.channels.length" class="border-t border-zinc-800/70 px-4 py-2">
|
||||
<div v-if="m.channels.length" class="border-t border-edge/70 px-4 py-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="b in m.channels"
|
||||
:key="b.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-400"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
|
||||
>
|
||||
{{ b.channel_name }} → {{ b.upstream_model }}
|
||||
<button class="text-zinc-400 hover:text-red-400" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
|
||||
<button class="text-muted hover:text-err" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="border-t border-zinc-800/70 px-4 py-2 text-xs text-zinc-600">
|
||||
<p v-else class="border-t border-edge/70 px-4 py-2 text-xs text-muted">
|
||||
未绑定渠道,客户端无法调用该模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-zinc-600">
|
||||
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
|
||||
还没有模型,点击「添加模型」或到渠道页「导入模型」
|
||||
</p>
|
||||
</div>
|
||||
@@ -196,10 +196,10 @@ onMounted(load)
|
||||
<!-- 绑定渠道 -->
|
||||
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-zinc-500">模型 <span class="font-mono text-emerald-300">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
|
||||
<p class="text-xs text-muted">模型 <span class="font-mono text-accent">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">渠道</span>
|
||||
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">渠道</span>
|
||||
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
||||
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -37,29 +37,29 @@ onMounted(load)
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-lg font-semibold">运营总览</h1>
|
||||
<p class="text-sm text-zinc-500">全局用户、渠道与营收</p>
|
||||
<p class="text-sm text-muted">全局用户、渠道与营收</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">用户 / 密钥</p>
|
||||
<p class="text-xs text-muted">用户 / 密钥</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="55%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">渠道 / 模型</p>
|
||||
<p class="text-xs text-muted">渠道 / 模型</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日请求</p>
|
||||
<p class="text-xs text-muted">今日请求</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.today.requests) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.today.requests) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">本月营收</p>
|
||||
<p class="text-xs text-muted">本月营收</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-emerald-300">{{ fmtCost(data.month.cost) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-accent">{{ fmtCost(data.month.cost) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ onMounted(load)
|
||||
<div class="card p-5 lg:col-span-3">
|
||||
<div class="mb-4 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">近 14 天全局成本</h2>
|
||||
<span class="font-mono text-[11px] text-zinc-600">{{ fmtCost(data.month.cost) }} / 本月</span>
|
||||
<span class="font-mono text-[11px] text-muted">{{ fmtCost(data.month.cost) }} / 本月</span>
|
||||
</div>
|
||||
<Skeleton v-if="loading" height="160px" />
|
||||
<TrendChart
|
||||
@@ -80,22 +80,22 @@ onMounted(load)
|
||||
<div class="card p-5 lg:col-span-2">
|
||||
<div class="mb-3 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">全局最近请求</h2>
|
||||
<span class="font-mono text-[11px] text-zinc-600">共 {{ data.month.requests }} / 月</span>
|
||||
<span class="font-mono text-[11px] text-muted">共 {{ data.month.requests }} / 月</span>
|
||||
</div>
|
||||
<ul class="divide-y divide-zinc-800/70">
|
||||
<ul class="divide-y divide-edge/70">
|
||||
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs text-zinc-300">
|
||||
<span class="font-mono text-emerald-400/80">{{ l.user }}</span> · {{ l.model }}
|
||||
<p class="truncate text-xs text-ink">
|
||||
<span class="font-mono text-accent">{{ l.user }}</span> · {{ l.model }}
|
||||
</p>
|
||||
<p class="font-mono text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p>
|
||||
<p class="font-mono text-[11px] text-muted">{{ fmtTime(l.created_at) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mono-num text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
|
||||
<span class="mono-num text-xs text-muted">{{ fmtCost(l.cost) }}</span>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">暂无请求</li>
|
||||
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">暂无请求</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,13 +93,13 @@ onMounted(load)
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">用户</h1>
|
||||
<p class="text-sm text-zinc-500">管理角色、状态与余额</p>
|
||||
<p class="text-sm text-muted">管理角色、状态与余额</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="q"
|
||||
placeholder="搜索用户名 / 邮箱"
|
||||
class="h-10 w-56 rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"
|
||||
class="h-10 w-56 rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<Button variant="ghost" @click="search">搜索</Button>
|
||||
@@ -110,7 +110,7 @@ onMounted(load)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">ID</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">用户名</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">邮箱</th>
|
||||
@@ -123,35 +123,35 @@ onMounted(load)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="table-row">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ u.id }}</td>
|
||||
<td class="px-4 py-2.5 text-zinc-200">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ u.id }}</td>
|
||||
<td class="px-4 py-2.5 text-ink">
|
||||
{{ u.username }}
|
||||
<span v-if="u.id === auth.user?.id" class="text-[11px] text-zinc-600">(我)</span>
|
||||
<span v-if="u.id === auth.user?.id" class="text-[11px] text-muted">(我)</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ u.email }}</td>
|
||||
<td class="px-4 py-2.5 text-xs text-muted">{{ u.email }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-emerald-300/90">{{ fmtMoney(u.balance) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-accent">{{ fmtMoney(u.balance) }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(u.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(u.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(u)">编辑</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBalance(u)">调余额</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(u)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-accent" @click="openBalance(u)">调余额</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="users.length === 0">
|
||||
<td colspan="8" class="px-4 py-10 text-center text-sm text-zinc-600">无用户</td>
|
||||
<td colspan="8" class="px-4 py-10 text-center text-sm text-muted">无用户</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
|
||||
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 人</span>
|
||||
<div class="flex items-center justify-between border-t border-edge px-4 py-3">
|
||||
<span class="font-mono text-xs text-muted">共 {{ total }} 人</span>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
|
||||
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
|
||||
@@ -163,15 +163,15 @@ onMounted(load)
|
||||
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">角色</span>
|
||||
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">角色</span>
|
||||
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">状态</span>
|
||||
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
|
||||
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
@@ -186,7 +186,7 @@ onMounted(load)
|
||||
<!-- 调整余额 -->
|
||||
<Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-zinc-500">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
|
||||
<p class="text-xs text-muted">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
|
||||
<Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" />
|
||||
<Input v-model="balanceForm.remark" label="备注" placeholder="可选" />
|
||||
</div>
|
||||
|
||||
@@ -51,30 +51,30 @@ onMounted(load)
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-lg font-semibold">仪表盘</h1>
|
||||
<p class="text-sm text-zinc-500">余额、用量与最近请求</p>
|
||||
<p class="text-sm text-muted">余额、用量与最近请求</p>
|
||||
</div>
|
||||
|
||||
<!-- 指标条 -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">余额</p>
|
||||
<p class="text-xs text-muted">余额</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="55%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-emerald-300">{{ fmtMoney(balance) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-accent">{{ fmtMoney(balance) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日请求</p>
|
||||
<p class="text-xs text-muted">今日请求</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.requests) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(today.requests) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日 Token</p>
|
||||
<p class="text-xs text-muted">今日 Token</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.tokens) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(today.tokens) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">近 30 日消耗</p>
|
||||
<p class="text-xs text-muted">近 30 日消耗</p>
|
||||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
|
||||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p>
|
||||
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtCost(monthCost) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ onMounted(load)
|
||||
<div class="card p-5 lg:col-span-3">
|
||||
<div class="mb-4 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">近 14 天成本</h2>
|
||||
<span class="font-mono text-[11px] text-zinc-600">{{ models }} 个可用模型</span>
|
||||
<span class="font-mono text-[11px] text-muted">{{ models }} 个可用模型</span>
|
||||
</div>
|
||||
<Skeleton v-if="loading" height="160px" />
|
||||
<TrendChart v-else :points="trend" :format="(v) => '$' + v.toExponential(2)" />
|
||||
@@ -95,25 +95,25 @@ onMounted(load)
|
||||
<h2 class="text-sm font-semibold">最近请求</h2>
|
||||
<router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link>
|
||||
</div>
|
||||
<ul v-if="loading" class="divide-y divide-zinc-800/70">
|
||||
<ul v-if="loading" class="divide-y divide-edge/70">
|
||||
<li v-for="i in 4" :key="i" class="flex items-center justify-between py-2">
|
||||
<Skeleton height="0.75rem" width="40%" />
|
||||
<Skeleton height="0.75rem" width="30%" />
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else class="divide-y divide-zinc-800/70">
|
||||
<ul v-else class="divide-y divide-edge/70">
|
||||
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-xs text-zinc-300">{{ l.model }}</p>
|
||||
<p class="text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p>
|
||||
<p class="truncate font-mono text-xs text-ink">{{ l.model }}</p>
|
||||
<p class="text-[11px] text-muted">{{ fmtTime(l.created_at) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
|
||||
<span class="mono-num w-16 text-right text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
|
||||
<span class="mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
|
||||
<span class="mono-num w-16 text-right text-xs text-muted">{{ fmtCost(l.cost) }}</span>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">
|
||||
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">
|
||||
还没有请求记录,去
|
||||
<router-link to="/console/keys" class="text-accent">API Keys</router-link>
|
||||
创建密钥开始调用
|
||||
|
||||
@@ -75,7 +75,7 @@ onMounted(load)
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">API Keys</h1>
|
||||
<p class="text-sm text-zinc-500">密钥明文仅在创建时展示一次,请立即保存</p>
|
||||
<p class="text-sm text-muted">密钥明文仅在创建时展示一次,请立即保存</p>
|
||||
</div>
|
||||
<Button @click="createOpen = true">新建密钥</Button>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@ onMounted(load)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
|
||||
@@ -95,17 +95,17 @@ onMounted(load)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="k in keys" :key="k.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-zinc-200">{{ k.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ k.key_prefix }}…</td>
|
||||
<td class="px-4 py-2.5 text-ink">{{ k.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ k.key_prefix }}…</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.last_used_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.last_used_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button
|
||||
v-if="k.status === 'active'"
|
||||
class="text-xs text-zinc-500 hover:text-red-400"
|
||||
class="text-xs text-muted hover:text-err"
|
||||
@click="revoke(k)"
|
||||
>
|
||||
吊销
|
||||
@@ -113,7 +113,7 @@ onMounted(load)
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="keys.length === 0">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-600">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-sm text-muted">
|
||||
还没有密钥,点击右上角「新建密钥」
|
||||
</td>
|
||||
</tr>
|
||||
@@ -134,9 +134,9 @@ onMounted(load)
|
||||
<!-- 一次性展示密钥 -->
|
||||
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-zinc-500">请复制并妥善保存,关闭后不再显示。</p>
|
||||
<p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="mono-num flex-1 truncate rounded-md border border-accent/40 bg-zinc-900 px-3 py-2 text-xs text-emerald-300">
|
||||
<code class="mono-num flex-1 truncate rounded-md border border-accent/40 bg-surface px-3 py-2 text-xs text-accent">
|
||||
{{ created?.key }}
|
||||
</code>
|
||||
<Button size="sm" @click="copyKey">
|
||||
|
||||
@@ -51,42 +51,42 @@ onMounted(load)
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-lg font-semibold">用量</h1>
|
||||
<p class="text-sm text-zinc-500">汇总、分布与请求明细</p>
|
||||
<p class="text-sm text-muted">汇总、分布与请求明细</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.requests) }}</p>
|
||||
<p class="text-xs text-muted">今日请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.requests) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日 Token</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.tokens) }}</p>
|
||||
<p class="text-xs text-muted">今日 Token</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.tokens) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">本月请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.month.requests) }}</p>
|
||||
<p class="text-xs text-muted">本月请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.month.requests) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">本月成本</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(summary.month.cost) }}</p>
|
||||
<p class="text-xs text-muted">本月成本</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtCost(summary.month.cost) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">成本分布</h2>
|
||||
<div class="flex gap-1 rounded-md border border-zinc-800 p-0.5">
|
||||
<div class="flex gap-1 rounded-md border border-edge p-0.5">
|
||||
<button
|
||||
class="rounded px-2.5 py-1 text-xs transition"
|
||||
:class="group === 'day' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
|
||||
:class="group === 'day' ? 'bg-surface2 text-ink' : 'text-muted'"
|
||||
@click="switchGroup('day')"
|
||||
>
|
||||
按天
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2.5 py-1 text-xs transition"
|
||||
:class="group === 'model' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
|
||||
:class="group === 'model' ? 'bg-surface2 text-ink' : 'text-muted'"
|
||||
@click="switchGroup('model')"
|
||||
>
|
||||
按模型
|
||||
@@ -97,19 +97,19 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||
<div class="flex items-center justify-between border-b border-edge px-4 py-3">
|
||||
<h2 class="text-sm font-semibold">请求明细</h2>
|
||||
<input
|
||||
v-model="modelFilter"
|
||||
placeholder="按模型过滤"
|
||||
class="h-8 w-48 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 font-mono text-xs outline-none focus:border-accent"
|
||||
class="h-8 w-48 rounded-md border border-edge2 bg-surface px-2.5 font-mono text-xs outline-none focus:border-accent"
|
||||
@keyup.enter="page = 1; load()"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">协议</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
|
||||
@@ -121,22 +121,22 @@ onMounted(load)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="l in logs" :key="l.id" class="table-row">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-200">{{ l.model }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ l.protocol }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-zinc-300">{{ fmtCost(l.cost) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-zinc-500">{{ l.latency_ms }}ms</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.protocol }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
|
||||
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(l.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="logs.length === 0">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">暂无请求记录</td>
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
|
||||
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 条</span>
|
||||
<div class="flex items-center justify-between border-t border-edge px-4 py-3">
|
||||
<span class="font-mono text-xs text-muted">共 {{ total }} 条</span>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
|
||||
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
|
||||
|
||||
Reference in New Issue
Block a user