M0+M1: 基建 + 用户/密钥/核心代理
后端 (Go/Gin/GORM): - 配置(viper+env)、SQLite/Postgres 迁移、argon2id、AES-GCM 渠道密钥、JWT+refresh cookie - 用户注册/登录/刷新/登出、API Key CRUD(仅存哈希、明文一次展示) - 代理网关: /v1/chat/completions、/v1/responses、/v1/models 直通 OpenAI 渠道 非流式+流式(SSE 零缓冲转发), 用量捕获(chat 末块/responses completed 嵌套), OpenAI 错误格式(401/402/404/502), 余额检查 - 异步批量记账 + 余额流水 + 日聚合, admin 用户/余额/配置 API - 单测: crypto/jwt/apikey/流式 usage 提取 前端 (Vue3+TS+Vite+Tailwind v4): - taste-skill 设计 tokens: 深色仪表盘, 石墨+信号铜色, Outfit+JetBrains Mono - Landing/登录/注册, 控制台(仪表盘图表/密钥管理/用量明细) - 基础组件 Button/Input/Badge/Modal, ECharts 用量图 部署: docker-compose(nginx+api+postgres), 双 Dockerfile, nginx SSE 反代 联调: scripts/mockupstream 本地 mock 上游, 端到端验证通过
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
onMounted(() => {
|
||||
// 初始化时若已有 token 则拉取用户信息
|
||||
if (auth.token && !auth.user) auth.fetchMe()
|
||||
void router
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
// API 客户端:统一 baseURL、token 注入、401 刷新兜底。
|
||||
import axios from 'axios'
|
||||
|
||||
// 代理端点(/v1/*,Bearer API Key)与管理 API(/api/v1)baseURL 不同,分开实例
|
||||
const proxyClient = axios.create({ baseURL: '/v1', timeout: 30000 })
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 20000,
|
||||
withCredentials: true, // refresh cookie
|
||||
})
|
||||
|
||||
export { proxyClient }
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('ot_access')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
let refreshing: Promise<string | null> | null = null
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err) => {
|
||||
const original = err.config
|
||||
// 401 且非刷新请求本身:尝试刷新一次
|
||||
if (err.response?.status === 401 && !original?._retried && !original?.url?.includes('/auth/')) {
|
||||
original._retried = true
|
||||
refreshing = refreshing ?? refreshAccess()
|
||||
const token = await refreshing
|
||||
refreshing = null
|
||||
if (token) {
|
||||
localStorage.setItem('ot_access', token)
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
return client(original)
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
async function refreshAccess(): Promise<string | null> {
|
||||
try {
|
||||
const { data } = await client.post('/auth/refresh')
|
||||
return data.data?.access_token ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 响应壳:{ data: {...} } 或 { error: {...} }
|
||||
export function unwrap<T>(p: Promise<{ data: { data?: T; error?: { message?: string } } }>): Promise<T> {
|
||||
return p.then((res) => {
|
||||
if (res.data.error) throw new Error(res.data.error.message || 'request failed')
|
||||
return res.data.data as T
|
||||
})
|
||||
}
|
||||
|
||||
export default client
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
// 状态信号灯徽标:healthy/degraded/cooldown/active/revoked/success/error
|
||||
const props = defineProps<{ tone: string; label?: string }>()
|
||||
|
||||
const tones: Record<string, { dot: string; text: string; bg: string }> = {
|
||||
healthy: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
|
||||
success: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
|
||||
active: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
|
||||
degraded: { dot: 'bg-signal-400', text: 'text-signal-300', bg: 'bg-signal-400/10' },
|
||||
cooldown: { dot: 'bg-signal-400', text: 'text-signal-300', bg: 'bg-signal-400/10' },
|
||||
error: { dot: 'bg-ember-400', text: 'text-ember-300', bg: 'bg-ember-400/10' },
|
||||
revoked: { dot: 'bg-ember-400', text: 'text-ember-300', bg: 'bg-ember-400/10' },
|
||||
disabled: { dot: 'bg-paper-600', text: 'text-paper-500', bg: 'bg-paper-600/10' },
|
||||
pending: { dot: 'bg-sky-400', text: 'text-sky-300', bg: 'bg-sky-400/10' },
|
||||
}
|
||||
|
||||
const t = tones[props.tone] ?? tones.disabled
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium" :class="[t.bg, t.text]">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="t.dot" />
|
||||
{{ label ?? tone }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'ghost' | 'danger' | 'outline'
|
||||
size?: 'sm' | 'md'
|
||||
type?: 'button' | 'submit'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ variant: 'primary', size: 'md', type: 'button', loading: false, disabled: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
class="inline-flex items-center justify-center gap-2 font-medium transition-all select-none
|
||||
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none cursor-pointer"
|
||||
:class="[
|
||||
size === 'sm' ? 'h-8 px-3 text-[13px] rounded-md' : 'h-10 px-4 text-sm rounded-md',
|
||||
variant === 'primary' && 'bg-signal-400 text-ink-950 hover:bg-signal-300',
|
||||
variant === 'outline' && 'border border-ink-600 text-paper-300 hover:border-signal-400 hover:text-signal-300 bg-transparent',
|
||||
variant === 'ghost' && 'text-paper-500 hover:text-paper-100 hover:bg-ink-800',
|
||||
variant === 'danger' && 'bg-ember-500/90 text-white hover:bg-ember-400',
|
||||
]"
|
||||
>
|
||||
<span v-if="loading" class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
label?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
modelValue?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
mono?: boolean
|
||||
autocomplete?: string
|
||||
}>(), { type: 'text', placeholder: '', modelValue: '' })
|
||||
|
||||
defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="block">
|
||||
<span v-if="label" class="mb-1.5 block text-[13px] font-medium text-paper-300">{{ label }}</span>
|
||||
<input
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
class="h-10 w-full rounded-md border bg-ink-900 px-3 text-sm text-paper-100 placeholder:text-paper-600
|
||||
transition-colors focus:border-signal-400 focus:outline-none"
|
||||
:class="[
|
||||
mono ? 'font-mono' : '',
|
||||
error ? 'border-ember-500' : 'border-ink-600 hover:border-ink-700',
|
||||
]"
|
||||
/>
|
||||
<span v-if="error" class="mt-1 block text-xs text-ember-400">{{ error }}</span>
|
||||
<span v-else-if="hint" class="mt-1 block text-xs text-paper-600">{{ hint }}</span>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; open: boolean; width?: string }>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-[12vh]">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-[2px]" @click="$emit('close')" />
|
||||
<div
|
||||
class="relative w-full rounded-lg border border-ink-700 bg-ink-900 shadow-2xl"
|
||||
:class="width ?? 'max-w-md'"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-ink-700 px-5 py-3.5">
|
||||
<h3 class="text-sm font-semibold text-paper-100">{{ title }}</h3>
|
||||
<button class="text-paper-500 transition-colors hover:text-paper-100 cursor-pointer" @click="$emit('close')" aria-label="关闭">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 py-4"><slot /></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
.modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.15s ease; }
|
||||
.modal-enter-from .relative, .modal-leave-to .relative { transform: translateY(6px) scale(0.99); }
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'landing', component: () => import('../views/LandingView.vue') },
|
||||
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { guest: true } },
|
||||
{ path: '/register', name: 'register', component: () => import('../views/RegisterView.vue'), meta: { guest: true } },
|
||||
{
|
||||
path: '/console',
|
||||
component: () => import('../views/console/ConsoleLayout.vue'),
|
||||
meta: { auth: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/console/dashboard' },
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/console/DashboardView.vue') },
|
||||
{ path: 'keys', name: 'keys', component: () => import('../views/console/KeysView.vue') },
|
||||
{ path: 'usage', name: 'usage', component: () => import('../views/console/UsageView.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.ready && auth.token) await auth.fetchMe()
|
||||
if (to.meta.auth && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } }
|
||||
if (to.meta.guest && auth.isAuthed) return { name: 'dashboard' }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import client, { unwrap } from '../api/client'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: 'user' | 'admin'
|
||||
balance: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface LoginResp {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
user: User
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null as User | null,
|
||||
token: localStorage.getItem('ot_access') ?? '',
|
||||
ready: false,
|
||||
}),
|
||||
getters: {
|
||||
isAuthed: (s) => !!s.token,
|
||||
isAdmin: (s) => s.user?.role === 'admin',
|
||||
},
|
||||
actions: {
|
||||
setToken(t: string) {
|
||||
this.token = t
|
||||
localStorage.setItem('ot_access', t)
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
const data = await unwrap<LoginResp>(client.post('/auth/login', { username, password }))
|
||||
this.setToken(data.access_token)
|
||||
this.user = data.user
|
||||
},
|
||||
async register(username: string, email: string, password: string) {
|
||||
await unwrap(client.post('/auth/register', { username, email, password }))
|
||||
},
|
||||
async fetchMe() {
|
||||
if (!this.token) return
|
||||
try {
|
||||
const data = await unwrap<{ user: User }>(client.get('/auth/me'))
|
||||
this.user = data.user
|
||||
} catch {
|
||||
this.logout()
|
||||
} finally {
|
||||
this.ready = true
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
try { await client.post('/auth/logout') } catch { /* ignore */ }
|
||||
this.user = null
|
||||
this.token = ''
|
||||
localStorage.removeItem('ot_access')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource/outfit/400.css";
|
||||
@import "@fontsource/outfit/500.css";
|
||||
@import "@fontsource/outfit/600.css";
|
||||
@import "@fontsource/outfit/700.css";
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
@import "@fontsource/jetbrains-mono/500.css";
|
||||
@import "@fontsource/jetbrains-mono/600.css";
|
||||
|
||||
/* ============================================================
|
||||
openteam 设计 tokens(taste-skill 产出)
|
||||
方向:深色优先的开发者控制台 / 信号系统语言
|
||||
色板:石墨墨底 + 暖白文本 + 单一信号铜色强调(信号灯)
|
||||
数据一律 mono(JetBrains Mono),UI 用 Outfit
|
||||
============================================================ */
|
||||
|
||||
@theme {
|
||||
/* 墨色层(背景阶梯) */
|
||||
--color-ink-950: #0c0d0f;
|
||||
--color-ink-900: #121417;
|
||||
--color-ink-850: #16191d;
|
||||
--color-ink-800: #1c2025;
|
||||
--color-ink-700: #282d34;
|
||||
--color-ink-600: #363c45;
|
||||
|
||||
/* 纸色层(文本) */
|
||||
--color-paper-100: #eae8e3;
|
||||
--color-paper-300: #c8c5bd;
|
||||
--color-paper-500: #8b909a;
|
||||
--color-paper-600: #63686f;
|
||||
|
||||
/* 信号铜色(唯一强调,信号灯意象) */
|
||||
--color-signal-200: #f7d9a8;
|
||||
--color-signal-300: #f0be6d;
|
||||
--color-signal-400: #e5a13c;
|
||||
--color-signal-500: #c9842a;
|
||||
--color-signal-600: #a56a1f;
|
||||
|
||||
/* 语义色 */
|
||||
--color-mint-300: #7fd0ac;
|
||||
--color-mint-400: #4cb58a;
|
||||
--color-mint-500: #33946f;
|
||||
--color-ember-300: #ec8a80;
|
||||
--color-ember-400: #d9685c;
|
||||
--color-ember-500: #b34c42;
|
||||
--color-sky-300: #93bce4;
|
||||
--color-sky-400: #6e9fd8;
|
||||
|
||||
--font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
|
||||
|
||||
/* 圆角:全局统一 6px(工具类,克制) */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
}
|
||||
|
||||
/* 亮色主题(保留:data-theme="light" 时切换,默认深色优先) */
|
||||
[data-theme="light"] {
|
||||
--color-ink-950: #f4f3f0;
|
||||
--color-ink-900: #ffffff;
|
||||
--color-ink-850: #faf9f6;
|
||||
--color-ink-800: #f0efeb;
|
||||
--color-ink-700: #e2e0da;
|
||||
--color-ink-600: #cfccc4;
|
||||
--color-paper-100: #1d2024;
|
||||
--color-paper-300: #3a3f46;
|
||||
--color-paper-500: #5f6670;
|
||||
--color-paper-600: #8a9099;
|
||||
--color-signal-400: #b3741c;
|
||||
--color-signal-500: #9a6116;
|
||||
--color-mint-400: #1f8a61;
|
||||
--color-ember-400: #c24b41;
|
||||
--color-sky-400: #3f78b8;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ink-950 text-paper-100 font-sans antialiased;
|
||||
font-feature-settings: "ss01" on, "cv05" on;
|
||||
}
|
||||
|
||||
/* 数字统一用 tabular 对齐(数据密集场景) */
|
||||
.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 聚焦可见性:键盘可达性 */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-signal-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 滚动条克制化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-ink-700);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--color-ink-950);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const primaryAction = computed(() => (auth.isAuthed ? '/console/dashboard' : '/register'))
|
||||
|
||||
const protocols = [
|
||||
{ name: 'POST /v1/chat/completions', desc: 'OpenAI Chat Completions · 流式 + 工具调用' },
|
||||
{ name: 'POST /v1/responses', desc: 'OpenAI Responses API · 新一代生态' },
|
||||
{ name: 'POST /v1/messages', desc: 'Anthropic Messages · Claude 原生格式' },
|
||||
{ name: 'GET /v1/models', desc: 'OpenAI 风格模型列表' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[100dvh] bg-ink-950 text-paper-100">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="sticky top-0 z-40 border-b border-ink-800 bg-ink-950/90 backdrop-blur">
|
||||
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
|
||||
<span class="ml-1 rounded border border-ink-700 px-1.5 py-px font-mono text-[10px] text-paper-500">relay</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-2">
|
||||
<a v-if="!auth.isAuthed" href="/login" class="rounded-md px-3 py-2 text-sm text-paper-500 transition-colors hover:text-paper-100">登录</a>
|
||||
<a :href="primaryAction" class="rounded-md bg-signal-400 px-4 py-2 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300">开始使用</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Hero:左对齐,信号路径示意 -->
|
||||
<section class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-14 px-6 pt-16 pb-20 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div>
|
||||
<p class="font-mono text-xs uppercase tracking-[0.2em] text-signal-400">self-hosted llm relay</p>
|
||||
<h1 class="mt-4 text-4xl leading-[1.05] font-bold tracking-tight md:text-5xl">
|
||||
一个 Key,<br />接入全部模型
|
||||
</h1>
|
||||
<p class="mt-5 max-w-[52ch] text-base leading-relaxed text-paper-500">
|
||||
自托管 LLM API 中转网关。统一 OpenAI 与 Anthropic 协议入口,背后对接任意上游渠道,用量计费一目了然。
|
||||
</p>
|
||||
<div class="mt-8 flex items-center gap-3">
|
||||
<a href="/register" class="inline-flex h-10 items-center rounded-md bg-signal-400 px-5 text-sm font-medium text-ink-950 transition-all hover:bg-signal-300 active:scale-[0.98]">立即开始</a>
|
||||
<a href="#protocols" class="inline-flex h-10 items-center rounded-md border border-ink-600 px-5 text-sm text-paper-300 transition-colors hover:border-signal-400 hover:text-signal-300">查看端点</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信号路径:client → gateway → upstream -->
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-5 font-mono text-[13px]">
|
||||
<div class="flex items-center gap-3 pb-4">
|
||||
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-mint-400" />
|
||||
<span class="text-xs text-paper-500">request path · live</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">client <span class="text-signal-400">──▶</span> <span class="text-paper-500">/v1/chat/completions</span></div>
|
||||
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">gateway <span class="text-signal-400">──▶</span> <span class="text-paper-500">auth · quota · route</span></div>
|
||||
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">upstream <span class="text-mint-400">◀──</span> <span class="text-paper-500">openai / anthropic</span></div>
|
||||
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">billing <span class="text-mint-400">──▶</span> <span class="num text-paper-500">usage · cost · ledger</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 协议端点 -->
|
||||
<section id="protocols" class="border-t border-ink-800 bg-ink-900/50">
|
||||
<div class="mx-auto max-w-6xl px-6 py-16">
|
||||
<h2 class="text-xl font-semibold tracking-tight">三套协议,一个入口</h2>
|
||||
<p class="mt-2 max-w-[60ch] text-sm leading-relaxed text-paper-500">
|
||||
OpenAI 与 Anthropic 生态的 SDK 与客户端无需改动,直接指向本网关。协议间自动互转。
|
||||
</p>
|
||||
<div class="mt-8 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div v-for="p in protocols" :key="p.name" class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||
<code class="font-mono text-[13px] text-signal-300">{{ p.name }}</code>
|
||||
<p class="mt-1.5 text-[13px] text-paper-500">{{ p.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="border-t border-ink-800">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-6 text-xs text-paper-600">
|
||||
<span>openteam · 自托管 LLM 中转网关</span>
|
||||
<span class="font-mono">v0.1 · M1</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import Input from '../components/ui/Input.vue'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
const redirect = (route.query.redirect as string) || '/console/dashboard'
|
||||
router.push(redirect)
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.error?.message || '登录失败,请检查用户名与密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 flex items-center gap-2.5">
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">登录控制台</h1>
|
||||
<p class="mt-1 text-sm text-paper-500">管理密钥、查看用量与余额</p>
|
||||
|
||||
<form class="mt-8 space-y-4" @submit.prevent="submit">
|
||||
<Input v-model="username" label="用户名或邮箱" placeholder="alice" autocomplete="username" />
|
||||
<Input v-model="password" label="密码" type="password" placeholder="••••••••" autocomplete="current-password" />
|
||||
<p v-if="error" class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
||||
<Button type="submit" class="w-full" :loading="loading">登录</Button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-[13px] text-paper-500">
|
||||
还没有账号?
|
||||
<router-link to="/register" class="text-signal-300 hover:text-signal-200">注册</router-link>
|
||||
</p>
|
||||
<p class="mt-4 text-center">
|
||||
<router-link to="/" class="text-xs text-paper-600 hover:text-paper-500">← 返回首页</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import Input from '../components/ui/Input.vue'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (password.value !== confirm.value) {
|
||||
error.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
if (password.value.length < 8) {
|
||||
error.value = '密码至少 8 位'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register(username.value, email.value, password.value)
|
||||
await auth.login(username.value, password.value)
|
||||
router.push('/console/dashboard')
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.error?.message || '注册失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 flex items-center gap-2.5">
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">创建账号</h1>
|
||||
<p class="mt-1 text-sm text-paper-500">注册即赠体验额度,一个 Key 接入全部模型</p>
|
||||
|
||||
<form class="mt-8 space-y-4" @submit.prevent="submit">
|
||||
<Input v-model="username" label="用户名" placeholder="alice" autocomplete="username" />
|
||||
<Input v-model="email" label="邮箱" type="email" placeholder="alice@example.com" autocomplete="email" />
|
||||
<Input v-model="password" label="密码" type="password" placeholder="至少 8 位" autocomplete="new-password" hint="使用 argon2id 加密存储" />
|
||||
<Input v-model="confirm" label="确认密码" type="password" placeholder="再次输入" autocomplete="new-password" />
|
||||
<p v-if="error" class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
||||
<Button type="submit" class="w-full" :loading="loading">注册</Button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-[13px] text-paper-500">
|
||||
已有账号?
|
||||
<router-link to="/login" class="text-signal-300 hover:text-signal-200">登录</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const nav = [
|
||||
{ to: '/console/dashboard', label: '仪表盘', icon: 'M3 12l9-9 9 9M5 10v10h5v-6h4v6h5V10' },
|
||||
{ to: '/console/keys', label: 'API 密钥', icon: 'M15 7a4 4 0 11-8 0 4 4 0 018 0zM3 21v-1a6 6 0 0112 0v1' },
|
||||
{ to: '/console/usage', label: '用量明细', icon: 'M4 20V10M10 20V4M16 20v-7M22 20H2' },
|
||||
]
|
||||
|
||||
const balanceFmt = computed(() =>
|
||||
auth.user ? auth.user.balance.toFixed(4) : '—',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] bg-ink-950">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="fixed inset-y-0 left-0 z-30 flex w-56 flex-col border-r border-ink-800 bg-ink-900/60">
|
||||
<div class="flex h-16 items-center gap-2.5 border-b border-ink-800 px-5">
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 space-y-0.5 px-3 py-4">
|
||||
<router-link
|
||||
v-for="item in nav"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center gap-3 rounded-md px-3 py-2 text-[13.5px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100"
|
||||
active-class="bg-ink-800 text-signal-300! font-medium"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="border-t border-ink-800 px-3 py-4">
|
||||
<div class="flex items-center justify-between rounded-md bg-ink-850 px-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[13px] font-medium text-paper-100">{{ auth.user?.username }}</p>
|
||||
<p class="text-xs text-paper-600">{{ auth.isAdmin ? 'admin' : 'user' }}</p>
|
||||
</div>
|
||||
<span class="num text-[13px] font-medium text-mint-400">${{ balanceFmt }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="mt-2 flex w-full items-center justify-center gap-2 rounded-md px-3 py-2 text-[13px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-ember-300 cursor-pointer"
|
||||
@click="auth.logout().then(() => router.push('/'))"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></svg>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<div class="ml-56 flex-1">
|
||||
<div class="mx-auto max-w-6xl px-8 py-8">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
import client, { unwrap } from '../../api/client'
|
||||
import Badge from '../../components/ui/Badge.vue'
|
||||
|
||||
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
||||
|
||||
interface BalanceInfo { balance: number; spent_last_30d: number; today: { requests: number; tokens: number; cost: number }; models_available: number }
|
||||
interface UsagePoint { date: string; requests: number; tokens: number; cost: number }
|
||||
interface LogItem { id: number; model: string; protocol: string; input_tokens: number; output_tokens: number; cost: number; latency_ms: number; status: string; created_at: string }
|
||||
|
||||
const balance = ref<BalanceInfo | null>(null)
|
||||
const stats = ref<UsagePoint[]>([])
|
||||
const recentLogs = ref<LogItem[]>([])
|
||||
const error = ref('')
|
||||
|
||||
const todayCost = computed(() => balance.value?.today.cost ?? 0)
|
||||
|
||||
const chartOption = computed(() => ({
|
||||
grid: { left: 8, right: 8, top: 24, bottom: 0, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#16191d',
|
||||
borderColor: '#282d34',
|
||||
textStyle: { color: '#eae8e3', fontSize: 12, fontFamily: 'JetBrains Mono' },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: stats.value.map((s) => s.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: '#282d34' } },
|
||||
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: '#1c2025' } },
|
||||
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '请求数',
|
||||
type: 'bar',
|
||||
data: stats.value.map((s) => s.requests),
|
||||
itemStyle: { color: '#e5a13c', borderRadius: [3, 3, 0, 0] },
|
||||
barMaxWidth: 22,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [b, s, logs] = await Promise.all([
|
||||
unwrap<BalanceInfo>(client.get('/user/balance')),
|
||||
unwrap<{ items: UsagePoint[] }>(client.get('/usage/stats', { params: { group: 'day' } })),
|
||||
unwrap<{ items: LogItem[] }>(client.get('/usage/logs', { params: { page_size: 8 } })),
|
||||
])
|
||||
balance.value = b
|
||||
stats.value = s.items ?? []
|
||||
recentLogs.value = logs.items ?? []
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '加载失败'
|
||||
}
|
||||
})
|
||||
|
||||
const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight">仪表盘</h1>
|
||||
<p class="mt-1 text-[13px] text-paper-500">今日与近 30 日用量总览</p>
|
||||
</div>
|
||||
<router-link to="/console/keys" class="rounded-md bg-signal-400 px-4 py-2 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300">新建密钥</router-link>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
||||
|
||||
<!-- 指标行 -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||
<p class="text-xs text-paper-500">余额</p>
|
||||
<p class="num mt-1.5 text-2xl font-semibold text-mint-400">${{ balance?.balance.toFixed(4) ?? '—' }}</p>
|
||||
<p class="num mt-1 text-[11px] text-paper-600">30 日消耗 ${{ fmtCost(balance?.spent_last_30d ?? 0) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||
<p class="text-xs text-paper-500">今日请求</p>
|
||||
<p class="num mt-1.5 text-2xl font-semibold">{{ balance?.today.requests ?? '—' }}</p>
|
||||
<p class="num mt-1 text-[11px] text-paper-600">{{ balance?.today.tokens ?? 0 }} tokens</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||
<p class="text-xs text-paper-500">今日成本</p>
|
||||
<p class="num mt-1.5 text-2xl font-semibold text-signal-300">${{ todayCost.toFixed(6) }}</p>
|
||||
<p class="num mt-1 text-[11px] text-paper-600">按量计费 · USD</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||
<p class="text-xs text-paper-500">可用模型</p>
|
||||
<p class="num mt-1.5 text-2xl font-semibold">{{ balance?.models_available ?? '—' }}</p>
|
||||
<p class="mt-1 text-[11px] text-paper-600">GET /v1/models 查看</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表 + 最近请求 -->
|
||||
<div class="mt-6 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-paper-300">近 30 日请求</h2>
|
||||
<span class="font-mono text-[11px] text-paper-600">usage/stats?group=day</span>
|
||||
</div>
|
||||
<VChart v-if="stats.length" class="h-56" :option="chartOption" autoresize />
|
||||
<div v-else class="flex h-56 items-center justify-center text-[13px] text-paper-600">暂无数据,发起第一次请求后这里会出现图表</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-paper-300">最近请求</h2>
|
||||
<router-link to="/console/usage" class="text-xs text-signal-300 hover:text-signal-200">全部 →</router-link>
|
||||
</div>
|
||||
<div v-if="recentLogs.length" class="divide-y divide-ink-800">
|
||||
<div v-for="l in recentLogs" :key="l.id" class="flex items-center justify-between gap-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-[12.5px] text-paper-100">{{ l.model }}</p>
|
||||
<p class="num mt-0.5 text-[11px] text-paper-600">{{ l.protocol }} · {{ l.input_tokens }}/{{ l.output_tokens }} tok · {{ l.latency_ms }}ms</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="num text-[12.5px] text-paper-300">${{ fmtCost(l.cost) }}</span>
|
||||
<Badge :tone="l.status === 'success' ? 'success' : 'error'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-48 items-center justify-center text-[13px] text-paper-600">还没有请求记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import client, { unwrap } from '../../api/client'
|
||||
import Badge from '../../components/ui/Badge.vue'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Modal from '../../components/ui/Modal.vue'
|
||||
import Input from '../../components/ui/Input.vue'
|
||||
|
||||
interface APIKey {
|
||||
id: number
|
||||
name: string
|
||||
key_prefix: string
|
||||
quota_tokens_per_day: number | null
|
||||
quota_requests_per_day: number | null
|
||||
status: string
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const keys = ref<APIKey[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 创建
|
||||
const showCreate = ref(false)
|
||||
const newName = ref('')
|
||||
const creating = ref(false)
|
||||
const createdKey = ref('')
|
||||
const createError = ref('')
|
||||
|
||||
// 吊销
|
||||
const revokeTarget = ref<APIKey | null>(null)
|
||||
const revoking = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await unwrap<{ items: APIKey[] }>(client.get('/keys'))
|
||||
keys.value = data.items
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
createError.value = ''
|
||||
if (!newName.value.trim()) {
|
||||
createError.value = '请填写密钥名称'
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const data = await unwrap<{ key: string }>(client.post('/keys', { name: newName.value.trim() }))
|
||||
createdKey.value = data.key
|
||||
newName.value = ''
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
createError.value = e.response?.data?.error?.message || '创建失败'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!revokeTarget.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
await unwrap(client.delete(`/keys/${revokeTarget.value.id}`))
|
||||
revokeTarget.value = null
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '吊销失败'
|
||||
} finally {
|
||||
revoking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
ta.remove()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleString('zh-CN', { hour12: false }) : '从未使用')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight">API 密钥</h1>
|
||||
<p class="mt-1 text-[13px] text-paper-500">密钥仅以 SHA-256 哈希存储,明文只在创建时展示一次</p>
|
||||
</div>
|
||||
<Button @click="showCreate = true">新建密钥</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
|
||||
<table class="w-full text-left text-[13px]">
|
||||
<thead>
|
||||
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
||||
<th class="px-4 py-3 font-medium">名称</th>
|
||||
<th class="px-4 py-3 font-medium">密钥前缀</th>
|
||||
<th class="px-4 py-3 font-medium">每日限额</th>
|
||||
<th class="px-4 py-3 font-medium">最近使用</th>
|
||||
<th class="px-4 py-3 font-medium">状态</th>
|
||||
<th class="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
|
||||
<tr v-for="k in keys" :key="k.id" class="transition-colors hover:bg-ink-850">
|
||||
<td class="px-4 py-3 font-medium text-paper-100">{{ k.name }}</td>
|
||||
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-signal-300">{{ k.key_prefix }}…</code></td>
|
||||
<td class="num px-4 py-3 text-paper-500">
|
||||
{{ k.quota_tokens_per_day ? `${(k.quota_tokens_per_day / 1000).toFixed(0)}k tok` : '—' }}
|
||||
/ {{ k.quota_requests_per_day ? `${k.quota_requests_per_day} req` : '—' }}
|
||||
</td>
|
||||
<td class="num px-4 py-3 text-paper-500">{{ fmtDate(k.last_used_at) }}</td>
|
||||
<td class="px-4 py-3"><Badge :tone="k.status" /></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
v-if="k.status === 'active'"
|
||||
class="text-xs text-ember-400 transition-colors hover:text-ember-300 cursor-pointer"
|
||||
@click="revokeTarget = k"
|
||||
>吊销</button>
|
||||
<span v-else class="text-xs text-paper-600">已吊销</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!keys.length">
|
||||
<td colspan="6" class="px-4 py-12 text-center text-[13px] text-paper-600">
|
||||
还没有密钥 — 点击右上角「新建密钥」创建第一个
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 创建模态 -->
|
||||
<Modal :open="showCreate" title="新建 API 密钥" @close="showCreate = false">
|
||||
<template v-if="!createdKey">
|
||||
<Input v-model="newName" label="密钥名称" placeholder="例如:本地开发" hint="用于在用量明细中区分来源" />
|
||||
<p v-if="createError" class="mt-3 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ createError }}</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" @click="showCreate = false">取消</Button>
|
||||
<Button :loading="creating" @click="create">创建</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-[13px] leading-relaxed text-paper-500">密钥已生成。出于安全考虑,<span class="text-paper-300">明文只会展示这一次</span>,请立即复制保存。</p>
|
||||
<div class="mt-3 flex items-center gap-2 rounded-md border border-mint-500/40 bg-mint-400/10 px-3 py-2.5">
|
||||
<code class="flex-1 break-all font-mono text-[12.5px] text-mint-300">{{ createdKey }}</code>
|
||||
<button
|
||||
class="shrink-0 text-xs text-mint-300 transition-colors hover:text-mint-400 cursor-pointer"
|
||||
@click="copyKey(createdKey)"
|
||||
>复制</button>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button @click="showCreate = false; createdKey = ''">完成</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 吊销确认 -->
|
||||
<Modal :open="!!revokeTarget" title="吊销密钥" @close="revokeTarget = null">
|
||||
<p class="text-[13px] leading-relaxed text-paper-500">
|
||||
吊销后 <code class="font-mono text-paper-300">{{ revokeTarget?.key_prefix }}…</code> 将立即失效,使用它的请求会返回 401。此操作不可撤销。
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" @click="revokeTarget = null">取消</Button>
|
||||
<Button variant="danger" :loading="revoking" @click="revoke">确认吊销</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import client, { unwrap } from '../../api/client'
|
||||
import Badge from '../../components/ui/Badge.vue'
|
||||
|
||||
interface LogItem {
|
||||
id: number
|
||||
request_id: string
|
||||
model: string
|
||||
protocol: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cost: number
|
||||
latency_ms: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const logs = ref<LogItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const modelFilter = ref('')
|
||||
const models = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await unwrap<{ items: LogItem[]; total: number }>(
|
||||
client.get('/usage/logs', { params: { page: page.value, page_size: pageSize, model: modelFilter.value || undefined } }),
|
||||
)
|
||||
logs.value = data.items
|
||||
total.value = data.total
|
||||
if (!modelFilter.value) {
|
||||
const m = await unwrap<{ items: string[] }>(client.get('/usage/logs', { params: { page_size: 1 } })).catch(() => ({ items: [] }))
|
||||
void m
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const data = await unwrap<{ items: string[] }>(client.get('/user/models'))
|
||||
models.value = data.items ?? []
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadModels()
|
||||
})
|
||||
|
||||
const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
|
||||
const fmtDate = (s: string) => new Date(s).toLocaleString('zh-CN', { hour12: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight">用量明细</h1>
|
||||
<p class="mt-1 text-[13px] text-paper-500">请求级记录 · 按当时价格入账</p>
|
||||
</div>
|
||||
<select
|
||||
v-model="modelFilter"
|
||||
class="h-9 rounded-md border border-ink-600 bg-ink-900 px-3 text-[13px] text-paper-300 focus:border-signal-400 focus:outline-none"
|
||||
@change="page = 1; load()"
|
||||
>
|
||||
<option value="">全部模型</option>
|
||||
<option v-for="m in models" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
|
||||
<table class="w-full text-left text-[13px]">
|
||||
<thead>
|
||||
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
||||
<th class="px-4 py-3 font-medium">时间</th>
|
||||
<th class="px-4 py-3 font-medium">模型</th>
|
||||
<th class="px-4 py-3 font-medium">协议</th>
|
||||
<th class="px-4 py-3 text-right font-medium">输入 tok</th>
|
||||
<th class="px-4 py-3 text-right font-medium">输出 tok</th>
|
||||
<th class="px-4 py-3 text-right font-medium">成本</th>
|
||||
<th class="px-4 py-3 text-right font-medium">耗时</th>
|
||||
<th class="px-4 py-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
|
||||
<tr v-for="l in logs" :key="l.id" class="transition-colors hover:bg-ink-850">
|
||||
<td class="num px-4 py-3 text-paper-500">{{ fmtDate(l.created_at) }}</td>
|
||||
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-paper-100">{{ l.model }}</code></td>
|
||||
<td class="px-4 py-3 font-mono text-[12px] text-paper-500">{{ l.protocol }}</td>
|
||||
<td class="num px-4 py-3 text-right text-paper-300">{{ l.input_tokens }}</td>
|
||||
<td class="num px-4 py-3 text-right text-paper-300">{{ l.output_tokens }}</td>
|
||||
<td class="num px-4 py-3 text-right text-signal-300">${{ fmtCost(l.cost) }}</td>
|
||||
<td class="num px-4 py-3 text-right text-paper-500">{{ l.latency_ms }}ms</td>
|
||||
<td class="px-4 py-3"><Badge :tone="l.status === 'success' ? 'success' : 'error'" /></td>
|
||||
</tr>
|
||||
<tr v-if="!logs.length">
|
||||
<td colspan="8" class="px-4 py-12 text-center text-[13px] text-paper-600">{{ loading ? '加载中…' : '暂无用量记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<p class="num text-xs text-paper-600">共 {{ total }} 条</p>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
class="rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default"
|
||||
:disabled="page <= 1"
|
||||
@click="page--; load()"
|
||||
>上一页</button>
|
||||
<span class="num px-2 text-xs text-paper-500">{{ page }} / {{ pages }}</span>
|
||||
<button
|
||||
class="rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default"
|
||||
:disabled="page >= pages"
|
||||
@click="page++; load()"
|
||||
>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user