M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)
- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、 API Key(sk- 48位, 仅存 SHA-256 哈希) - 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回 - 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先) - 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合 - 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置 - 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台, 自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查 - mock 上游: OpenAI+Anthropic 双协议模拟(含流式) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+4
-8
@@ -1,17 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ToastHost from '@/components/ui/ToastHost.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
onMounted(() => {
|
||||
// 初始化时若已有 token 则拉取用户信息
|
||||
if (auth.token && !auth.user) auth.fetchMe()
|
||||
void router
|
||||
})
|
||||
onMounted(() => auth.bootstrap())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<ToastHost />
|
||||
</template>
|
||||
|
||||
+30
-46
@@ -1,60 +1,44 @@
|
||||
// API 客户端:统一 baseURL、token 注入、401 刷新兜底。
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 代理端点(/v1/*,Bearer API Key)与管理 API(/api/v1)baseURL 不同,分开实例
|
||||
const proxyClient = axios.create({ baseURL: '/v1', timeout: 30000 })
|
||||
|
||||
const client = axios.create({
|
||||
export const http = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 20000,
|
||||
withCredentials: true, // refresh cookie
|
||||
timeout: 15000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
export { proxyClient }
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('ot_access')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
http.interceptors.request.use((config) => {
|
||||
const auth = useAuthStore()
|
||||
if (auth.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
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)
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const auth = useAuthStore()
|
||||
// 尝试用 refresh cookie 换新 token 后再试一次
|
||||
if (auth.accessToken && !error.config?._retried) {
|
||||
error.config._retried = true
|
||||
try {
|
||||
await auth.refresh()
|
||||
return http.request(error.config)
|
||||
} catch {
|
||||
auth.clear()
|
||||
}
|
||||
} else {
|
||||
auth.clear()
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
async function refreshAccess(): Promise<string | null> {
|
||||
try {
|
||||
const { data } = await client.post('/auth/refresh')
|
||||
return data.data?.access_token ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// 统一取后端错误信息
|
||||
export function errMsg(err: unknown): string {
|
||||
const e = err as { response?: { data?: { error?: { message?: string } } } }
|
||||
return e?.response?.data?.error?.message ?? '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
// 响应壳:{ 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.
|
Before Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PhList } from '@phosphor-icons/vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fmtMoney } from '@/lib/format'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
defineProps<{ sections: { title: string; items: NavItem[] }[] }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const balance = computed(() => fmtMoney(auth.user?.balance ?? 0))
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
function navTo() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] bg-zinc-950">
|
||||
<!-- 移动端遮罩 -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 z-30 bg-black/60 md:hidden"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<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="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex h-14 items-center gap-2 border-b border-zinc-800/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">
|
||||
{{ 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"
|
||||
@click="navTo"
|
||||
>
|
||||
{{ n.label }}
|
||||
</router-link>
|
||||
</template>
|
||||
</nav>
|
||||
</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">
|
||||
<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"
|
||||
aria-label="打开菜单"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<PhList :size="20" />
|
||||
</button>
|
||||
<span class="font-mono text-xs text-zinc-600">{{ 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">
|
||||
余额 {{ balance }}
|
||||
</span>
|
||||
<button
|
||||
class="rounded-md px-2 py-1 text-xs text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
@click="logout"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-4 py-6 md:px-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +1,30 @@
|
||||
<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
|
||||
withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent' }>(), {
|
||||
variant: 'neutral',
|
||||
})
|
||||
</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
|
||||
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',
|
||||
}[variant]"
|
||||
>
|
||||
<span
|
||||
v-if="variant !== 'neutral'"
|
||||
class="size-1.5 rounded-full"
|
||||
:class="{
|
||||
ok: 'bg-ok',
|
||||
warn: 'bg-warn',
|
||||
err: 'bg-err',
|
||||
accent: 'bg-accent',
|
||||
}[variant]"
|
||||
/>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,42 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'ghost' | 'danger' | 'outline'
|
||||
variant?: 'primary' | 'ghost' | 'danger'
|
||||
size?: 'sm' | 'md'
|
||||
type?: 'button' | 'submit'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{ variant: 'primary', size: 'md', type: 'button', loading: false, disabled: false },
|
||||
{ variant: 'primary', size: 'md', loading: false, disabled: false },
|
||||
)
|
||||
|
||||
defineEmits<{ (e: 'click', ev: MouseEvent): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-busy="loading || undefined"
|
||||
class="inline-flex touch-manipulation items-center justify-center gap-2 font-medium select-none
|
||||
transition-[background-color,border-color,color,transform,opacity] duration-150
|
||||
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none cursor-pointer"
|
||||
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-[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',
|
||||
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',
|
||||
]"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,79 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId } from 'vue'
|
||||
|
||||
type InputMode = 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'
|
||||
|
||||
type Props = {
|
||||
label?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
modelValue?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
mono?: boolean
|
||||
autocomplete?: string
|
||||
name?: string
|
||||
inputmode?: InputMode
|
||||
spellcheck?: boolean
|
||||
required?: boolean
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
modelValue: '',
|
||||
spellcheck: false,
|
||||
})
|
||||
|
||||
defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
|
||||
const uid = useId()
|
||||
const inputId = `input-${uid}`
|
||||
const descId = computed(() => (props.error || props.hint ? `desc-${uid}` : undefined))
|
||||
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
// 暴露 focus 供父组件定位错误
|
||||
function focus() {
|
||||
inputEl.value?.focus()
|
||||
}
|
||||
defineExpose({ focus })
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
modelValue?: string | number
|
||||
type?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
autocomplete?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ type: 'text', modelValue: '', disabled: false },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label v-if="label" :for="inputId" class="mb-1.5 block text-[13px] font-medium text-paper-300">
|
||||
{{ label }}<span v-if="required" class="ml-0.5 text-ember-400" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span v-if="label" class="mb-1.5 block text-xs font-medium text-zinc-400">{{ label }}</span>
|
||||
<input
|
||||
ref="inputEl"
|
||||
:id="inputId"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:inputmode="inputmode"
|
||||
:spellcheck="spellcheck"
|
||||
:required="required"
|
||||
:autofocus="autofocus"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:aria-describedby="descId"
|
||||
@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 transition-[border-color,box-shadow] placeholder:text-paper-600
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-400/60"
|
||||
:class="[
|
||||
mono ? 'font-mono' : '',
|
||||
error ? 'border-ember-500' : 'border-ink-600 hover:border-ink-700',
|
||||
]"
|
||||
: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="error && 'border-err focus:border-err focus:ring-err/30'"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
|
||||
/>
|
||||
<p v-if="error" :id="descId" class="mt-1 flex items-start gap-1 text-xs text-ember-400" role="alert">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="mt-px shrink-0" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3" />
|
||||
<path d="M8 5v3.5M8 11h.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="hint" :id="descId" class="mt-1 text-xs text-paper-600">{{ hint }}</p>
|
||||
</div>
|
||||
<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>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -1,100 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { PhX } from '@phosphor-icons/vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ title: string; open: boolean; width?: string }>(),
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
width?: string
|
||||
}>(),
|
||||
{ width: 'max-w-md' },
|
||||
)
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const lastFocus = ref<HTMLElement | null>(null)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab' && dialogRef.value) {
|
||||
// focus trap:Tab 循环
|
||||
const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (!focusables.length) return
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
|
||||
// 打开时锁背景滚动并聚焦面板,关闭时恢复
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
async (open) => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
if (open) {
|
||||
lastFocus.value = document.activeElement as HTMLElement
|
||||
document.body.style.overflow = 'hidden'
|
||||
// 等 DOM 渲染后聚焦第一个可聚焦元素
|
||||
requestAnimationFrame(() => {
|
||||
const first = dialogRef.value?.querySelector<HTMLElement>('button, [href], input, select, textarea')
|
||||
first?.focus()
|
||||
})
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
lastFocus.value?.focus()
|
||||
lastFocus.value = null
|
||||
await nextTick()
|
||||
panel.value?.focus()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-[12vh]"
|
||||
style="overscroll-behavior: contain; touch-action: manipulation"
|
||||
@keydown="onKeydown"
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-[12vh] backdrop-blur-sm"
|
||||
@mousedown.self="emit('close')"
|
||||
>
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-[2px]" aria-hidden="true" @click="emit('close')" />
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="relative w-full rounded-lg border border-ink-700 bg-ink-900 shadow-2xl"
|
||||
:class="width"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="`modal-title-${title}`"
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-150"
|
||||
enter-from-class="scale-[0.97] opacity-0"
|
||||
leave-active-class="transition-transform duration-150"
|
||||
leave-to-class="scale-[0.97] opacity-0"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-ink-700 px-5 py-3.5">
|
||||
<h3 :id="`modal-title-${title}`" class="text-sm font-semibold text-paper-100">{{ title }}</h3>
|
||||
<button
|
||||
class="touch-manipulation rounded-md p-1 text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none cursor-pointer"
|
||||
@click="emit('close')"
|
||||
aria-label="关闭对话框"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title || '对话框'"
|
||||
tabindex="-1"
|
||||
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')">
|
||||
<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">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-4"><slot /></div>
|
||||
</div>
|
||||
</Transition>
|
||||
</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>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
|
||||
const toast = useToastStore()
|
||||
|
||||
const styles: Record<string, string> = {
|
||||
success: 'border-mint-500/40 bg-mint-400/10 text-mint-300',
|
||||
error: 'border-ember-500/40 bg-ember-500/10 text-ember-300',
|
||||
info: 'border-sky-500/40 bg-sky-400/10 text-sky-300',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none fixed right-4 top-4 z-[60] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toast.items"
|
||||
:key="t.id"
|
||||
class="pointer-events-auto flex items-start gap-2.5 rounded-md border bg-ink-900/95 px-3.5 py-2.5 text-[13px] shadow-lg backdrop-blur"
|
||||
:class="styles[t.kind]"
|
||||
role="status"
|
||||
>
|
||||
<span class="mt-px shrink-0" aria-hidden="true">
|
||||
<svg v-if="t.kind === 'success'" width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/><path d="M5 8.2l2 2 4-4.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<svg v-else-if="t.kind === 'error'" width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/><path d="M8 5v3.5M8 11h.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 break-words">{{ t.message }}</span>
|
||||
<button
|
||||
class="shrink-0 text-current opacity-60 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current focus:outline-none cursor-pointer"
|
||||
:aria-label="`关闭通知`"
|
||||
@click="toast.dismiss(t.id)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-6px); }
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
const toast = useToastStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed right-4 bottom-4 z-[80] flex w-80 flex-col gap-2" aria-live="polite">
|
||||
<TransitionGroup
|
||||
enter-active-class="transition-all duration-200"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
leave-active-class="transition-all duration-200"
|
||||
leave-to-class="translate-y-1 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-for="t in toast.items"
|
||||
:key="t.id"
|
||||
class="card flex items-start gap-2.5 px-4 py-3 shadow-lg"
|
||||
:class="t.type === 'err' && 'border-err/40'"
|
||||
>
|
||||
<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'"
|
||||
/>
|
||||
<p class="text-sm text-zinc-200">{{ t.msg }}</p>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
points: { label: string; value: number }[]
|
||||
height?: number
|
||||
format?: (v: number) => string
|
||||
}>(),
|
||||
{ height: 160, format: (v: number) => String(v) },
|
||||
)
|
||||
|
||||
const chart = computed(() => {
|
||||
const max = Math.max(...props.points.map((p) => p.value), 1)
|
||||
const bw = 100 / props.points.length
|
||||
const bars = props.points.map((p, i) => ({
|
||||
x: i * bw,
|
||||
w: Math.max(bw * 0.5, 2),
|
||||
h: (p.value / max) * 100,
|
||||
label: p.label,
|
||||
value: props.format(p.value),
|
||||
}))
|
||||
return { bars, maxLabel: props.format(max) }
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<svg
|
||||
:viewBox="`0 0 100 ${height}`"
|
||||
:height="height"
|
||||
class="w-full"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
:aria-label="`用量趋势,共 ${points.length} 天`"
|
||||
>
|
||||
<!-- 基线 -->
|
||||
<line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="rgb(39 39 42)" stroke-width="0.6" />
|
||||
<g v-for="b in chart.bars" :key="b.label">
|
||||
<rect
|
||||
:x="b.x"
|
||||
:y="height - 18 - b.h"
|
||||
:width="b.w"
|
||||
:height="b.h"
|
||||
rx="0.8"
|
||||
class="fill-accent/80 hover:fill-accent"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="mt-1 flex justify-between font-mono text-[10px] text-zinc-600">
|
||||
<span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
export function fmtMoney(v: number): string {
|
||||
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })
|
||||
}
|
||||
|
||||
export function fmtNum(v: number): string {
|
||||
return v.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function fmtTime(s?: string | null): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return '-'
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export function fmtCost(v: number): string {
|
||||
if (v === 0) return '$0'
|
||||
if (v < 0.01) return '$' + v.toExponential(2)
|
||||
return fmtMoney(v)
|
||||
}
|
||||
+5
-2
@@ -1,8 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { router } from './router'
|
||||
import './style.css'
|
||||
|
||||
// 深色优先(MVP 固定深色,后续加亮色切换)
|
||||
document.documentElement.classList.add('dark')
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
|
||||
+24
-12
@@ -1,21 +1,34 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
export 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: '/', 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'),
|
||||
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: '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: '/admin',
|
||||
component: () => import('@/views/admin/AdminLayout.vue'),
|
||||
meta: { auth: true, admin: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/admin/overview' },
|
||||
{ path: 'overview', name: 'admin-overview', component: () => import('@/views/admin/OverviewView.vue') },
|
||||
{ path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') },
|
||||
{ path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') },
|
||||
{ path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
@@ -24,10 +37,9 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.ready && auth.token) await auth.fetchMe()
|
||||
if (!auth.ready) await auth.bootstrap()
|
||||
if (to.meta.auth && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } }
|
||||
if (to.meta.admin && !auth.isAdmin) return { name: 'dashboard' }
|
||||
if (to.meta.guest && auth.isAuthed) return { name: 'dashboard' }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
+38
-39
@@ -1,60 +1,59 @@
|
||||
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
|
||||
}
|
||||
import { http } from '@/api/client'
|
||||
import type { User } from '@/types'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null as User | null,
|
||||
token: localStorage.getItem('ot_access') ?? '',
|
||||
accessToken: localStorage.getItem('ot_access') ?? '',
|
||||
ready: false,
|
||||
}),
|
||||
getters: {
|
||||
isAuthed: (s) => !!s.token,
|
||||
isAuthed: (s) => !!s.accessToken && !!s.user,
|
||||
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 bootstrap() {
|
||||
try {
|
||||
if (!this.accessToken) {
|
||||
await this.refresh()
|
||||
}
|
||||
await this.fetchMe()
|
||||
} catch {
|
||||
this.clear()
|
||||
}
|
||||
this.ready = true
|
||||
},
|
||||
async register(username: string, email: string, password: string) {
|
||||
await unwrap(client.post('/auth/register', { username, email, password }))
|
||||
await http.post('/auth/register', { username, email, password })
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
const { data } = await http.post('/auth/login', { username, password })
|
||||
const d = data.data as { access_token: string; user: User }
|
||||
this.accessToken = d.access_token
|
||||
this.user = d.user
|
||||
localStorage.setItem('ot_access', d.access_token)
|
||||
},
|
||||
async refresh() {
|
||||
const { data } = await http.post('/auth/refresh')
|
||||
this.accessToken = data.data.access_token as string
|
||||
localStorage.setItem('ot_access', this.accessToken)
|
||||
},
|
||||
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
|
||||
}
|
||||
const { data } = await http.get('/auth/me')
|
||||
this.user = data.data.user as User
|
||||
},
|
||||
async logout() {
|
||||
try { await client.post('/auth/logout') } catch { /* ignore */ }
|
||||
try {
|
||||
await http.post('/auth/logout')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.clear()
|
||||
},
|
||||
clear() {
|
||||
this.accessToken = ''
|
||||
this.user = null
|
||||
this.token = ''
|
||||
localStorage.removeItem('ot_access')
|
||||
},
|
||||
},
|
||||
|
||||
+15
-13
@@ -1,27 +1,29 @@
|
||||
// Toast 状态:轻量全局消息队列(操作反馈,aria-live 播报)
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface Toast {
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
kind: 'success' | 'error' | 'info'
|
||||
message: string
|
||||
msg: string
|
||||
type: 'info' | 'ok' | 'err'
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
|
||||
export const useToastStore = defineStore('toast', {
|
||||
state: () => ({ items: [] as Toast[] }),
|
||||
state: () => ({ items: [] as ToastItem[] }),
|
||||
actions: {
|
||||
push(kind: Toast['kind'], message: string) {
|
||||
push(msg: string, type: ToastItem['type'] = 'info') {
|
||||
const id = ++seq
|
||||
this.items.push({ id, kind, message })
|
||||
setTimeout(() => this.dismiss(id), 4000)
|
||||
this.items.push({ id, msg, type })
|
||||
setTimeout(() => this.remove(id), 4000)
|
||||
},
|
||||
success(message: string) { this.push('success', message) },
|
||||
error(message: string) { this.push('error', message) },
|
||||
info(message: string) { this.push('info', message) },
|
||||
dismiss(id: number) {
|
||||
this.items = this.items.filter((t) => t.id !== id)
|
||||
ok(msg: string) {
|
||||
this.push(msg, 'ok')
|
||||
},
|
||||
err(msg: string) {
|
||||
this.push(msg, 'err')
|
||||
},
|
||||
remove(id: number) {
|
||||
this.items = this.items.filter((i) => i.id !== id)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+43
-99
@@ -1,122 +1,66 @@
|
||||
@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";
|
||||
@import 'tailwindcss';
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource-variable/geist-mono';
|
||||
|
||||
/* ============================================================
|
||||
openteam 设计 tokens(taste-skill 产出)
|
||||
方向:深色优先的开发者控制台 / 信号系统语言
|
||||
色板:石墨墨底 + 暖白文本 + 单一信号铜色强调(信号灯)
|
||||
数据一律 mono(JetBrains Mono),UI 用 Outfit
|
||||
============================================================ */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 设计 tokens(taste-skill 产出) */
|
||||
/* 深色优先 · 单一强调色 emerald · 圆角体系:卡片 8 / 控件 6 / 徽章 pill */
|
||||
/* 密度 7:mono 数字、紧凑表格、细线分隔 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@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;
|
||||
--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-paper-100: #eae8e3;
|
||||
--color-paper-300: #c8c5bd;
|
||||
--color-paper-500: #8b909a;
|
||||
--color-paper-600: #63686f;
|
||||
/* 强调色(单一一处定义,全局一致) */
|
||||
--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-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;
|
||||
/* 状态色(语义,克制使用) */
|
||||
--color-ok: oklch(0.72 0.17 152);
|
||||
--color-warn: oklch(0.80 0.15 75);
|
||||
--color-err: oklch(0.63 0.21 25);
|
||||
}
|
||||
|
||||
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;
|
||||
background-color: #09090b;
|
||||
color: #f4f4f5;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 数字统一用 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: 2px solid var(--color-accent);
|
||||
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;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: 'user' | 'admin'
|
||||
balance: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: number
|
||||
name: string
|
||||
key_prefix: string
|
||||
quota_tokens_per_day?: number | null
|
||||
quota_requests_per_day?: number | null
|
||||
allowed_models?: string[] | null
|
||||
expires_at?: string | null
|
||||
status: string
|
||||
last_used_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: number
|
||||
name: string
|
||||
provider: 'openai' | 'anthropic' | 'compatible'
|
||||
base_url: string
|
||||
api_key_masked: string
|
||||
weight: number
|
||||
priority: number
|
||||
timeout_ms: number
|
||||
max_concurrency: number
|
||||
health_status: string
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ModelBinding {
|
||||
id: number
|
||||
channel_id: number
|
||||
channel_name: string
|
||||
upstream_model: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
input_price: number
|
||||
output_price: number
|
||||
cache_read_price: number
|
||||
enabled: boolean
|
||||
sort: number
|
||||
channels: ModelBinding[]
|
||||
}
|
||||
|
||||
export interface UsageLog {
|
||||
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
|
||||
error_code: string | null
|
||||
created_at: string
|
||||
user?: string
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
+145
-76
@@ -1,105 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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">
|
||||
<router-link to="/" class="flex items-center gap-2.5" aria-label="openteam 首页">
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400" aria-hidden="true">
|
||||
<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" translate="no">openteam</span>
|
||||
<span class="ml-1 rounded border border-ink-700 px-1.5 py-px font-mono text-[10px] text-paper-500" translate="no">relay</span>
|
||||
</router-link>
|
||||
<nav class="flex items-center gap-2" aria-label="站内导航">
|
||||
<div class="min-h-[100dvh] bg-zinc-950 text-zinc-100">
|
||||
<!-- 导航 -->
|
||||
<header class="sticky top-0 z-40 border-b border-zinc-800/60 bg-zinc-950/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">
|
||||
<router-link
|
||||
v-if="!auth.isAuthed"
|
||||
to="/login"
|
||||
class="rounded-md px-3 py-2 text-sm text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
>登录</router-link>
|
||||
class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100"
|
||||
>
|
||||
登录
|
||||
</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>
|
||||
<router-link
|
||||
:to="primaryAction"
|
||||
class="inline-flex h-9 touch-manipulation items-center rounded-md bg-signal-400 px-4 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
>开始使用</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"
|
||||
>
|
||||
免费注册
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="landing-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" translate="no">self-hosted llm relay</p>
|
||||
<h1 class="mt-4 text-4xl leading-[1.05] font-bold tracking-tight text-balance md:text-5xl">
|
||||
一个 Key,<br />接入全部模型
|
||||
<!-- Hero:左文案 + 右真实调用演示 -->
|
||||
<section class="relative overflow-hidden">
|
||||
<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>
|
||||
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl">
|
||||
一个 Key,调用所有主流模型
|
||||
</h1>
|
||||
<p class="mt-5 max-w-[52ch] text-base leading-relaxed text-paper-500">
|
||||
自托管 LLM API 中转网关。统一 OpenAI 与 Anthropic 协议入口,背后对接任意上游渠道,用量计费一目了然。
|
||||
<p class="mt-5 max-w-md text-base leading-relaxed text-zinc-400">
|
||||
统一 OpenAI 与 Anthropic 协议入口,三套 API 自动互转,用量、计费与 API Key 管理开箱即用。
|
||||
</p>
|
||||
<div class="mt-8 flex items-center gap-3">
|
||||
<router-link
|
||||
to="/register"
|
||||
class="inline-flex h-10 touch-manipulation items-center rounded-md bg-signal-400 px-5 text-sm font-medium text-ink-950 transition-[background-color,transform] hover:bg-signal-300 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
>立即开始</router-link>
|
||||
<a
|
||||
href="#protocols"
|
||||
class="inline-flex h-10 touch-manipulation 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 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
>查看端点</a>
|
||||
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]"
|
||||
>
|
||||
免费注册
|
||||
</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"
|
||||
>
|
||||
登录
|
||||
</router-link>
|
||||
</div>
|
||||
<p class="mt-5 font-mono text-xs text-zinc-600">不用换 SDK,改一行 base_url 即可接入</p>
|
||||
</div>
|
||||
|
||||
<!-- 信号路径:client → gateway → upstream -->
|
||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-5 font-mono text-[13px]" aria-label="请求链路示意">
|
||||
<div class="flex items-center gap-3 pb-4">
|
||||
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-mint-400" aria-hidden="true" />
|
||||
<span class="text-xs text-paper-500" translate="no">request path · live</span>
|
||||
<!-- 调用演示(真实格式,非伪截图) -->
|
||||
<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>
|
||||
<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" aria-hidden="true">──▶</span> <span class="text-paper-500" translate="no">/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" aria-hidden="true">──▶</span> <span class="text-paper-500" translate="no">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" aria-hidden="true">◀──</span> <span class="text-paper-500" translate="no">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" aria-hidden="true">──▶</span> <span class="num text-paper-500" translate="no">usage · cost · ledger</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 协议端点 -->
|
||||
<section id="protocols" class="scroll-mt-16 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" translate="no">{{ p.name }}</code>
|
||||
<p class="mt-1.5 text-[13px] text-paper-500">{{ p.desc }}</p>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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" translate="no">v0.1 · M1</span>
|
||||
<!-- 协议入口 -->
|
||||
<section class="border-t border-zinc-800/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">
|
||||
对客户端暴露统一的 OpenAI 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。
|
||||
</p>
|
||||
<div class="card mt-8 divide-y divide-zinc-800/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>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 网关能力 -->
|
||||
<section class="border-t border-zinc-800/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>
|
||||
<h3 class="mt-2 text-lg font-semibold">协议自动转换</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="font-mono text-xs text-zinc-600">usage · daily</p>
|
||||
<h3 class="mt-2 text-lg font-semibold">用量与计费</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="font-mono text-xs text-zinc-600">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>
|
||||
</div>
|
||||
<div class="card p-5 lg:col-span-2">
|
||||
<p class="font-mono text-xs text-zinc-600">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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="border-t border-zinc-800/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>
|
||||
<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]"
|
||||
>
|
||||
开始使用
|
||||
</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">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-4" />
|
||||
<span>openteam · LLM API 中转站</span>
|
||||
</div>
|
||||
<span class="font-mono">© 2026</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+30
-55
@@ -1,32 +1,33 @@
|
||||
<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'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { errMsg } from '@/api/client'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (!username.value.trim() || !password.value) {
|
||||
error.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
if (!username.value || !password.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await auth.login(username.value.trim(), password.value)
|
||||
await auth.login(username.value, password.value)
|
||||
toast.ok('登录成功')
|
||||
const redirect = (route.query.redirect as string) || '/console/dashboard'
|
||||
router.push(redirect)
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.error?.message || '登录失败,请检查用户名与密码'
|
||||
} catch (e) {
|
||||
error.value = errMsg(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -34,52 +35,26 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-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" aria-hidden="true">
|
||||
<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" translate="no">openteam</span>
|
||||
<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>
|
||||
</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" novalidate>
|
||||
<Input
|
||||
v-model="username"
|
||||
label="用户名或邮箱"
|
||||
name="username"
|
||||
placeholder="alice"
|
||||
autocomplete="username"
|
||||
:spellcheck="false"
|
||||
autofocus
|
||||
/>
|
||||
<Input
|
||||
v-model="password"
|
||||
label="密码"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="输入密码…"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<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"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>{{ error }}</p>
|
||||
<Button type="submit" class="w-full" :loading="loading">登录</Button>
|
||||
<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>
|
||||
<Button class="w-full" :loading="loading" type="submit">登录</Button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-[13px] text-paper-500">
|
||||
还没有账号?
|
||||
<router-link to="/register" class="text-signal-300 transition-colors hover:text-signal-200">注册</router-link>
|
||||
</p>
|
||||
<p class="mt-4 text-center">
|
||||
<router-link to="/" class="text-xs text-paper-600 transition-colors hover:text-paper-500">← 返回首页</router-link>
|
||||
<p class="mt-5 text-center text-sm text-zinc-500">
|
||||
还没有账号?
|
||||
<router-link to="/register" class="text-accent hover:text-accent-strong">注册</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
<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'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { errMsg } from '@/api/client'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const formError = ref('')
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
|
||||
function validate() {
|
||||
const e: Record<string, string> = {}
|
||||
if (!username.value.trim()) e.username = '请输入用户名'
|
||||
else if (username.value.length < 3) e.username = '用户名至少 3 个字符'
|
||||
if (!email.value.trim()) e.email = '请输入邮箱'
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) e.email = '邮箱格式不正确'
|
||||
if (!password.value) e.password = '请输入密码'
|
||||
else if (password.value.length < 8) e.password = '密码至少 8 位'
|
||||
if (confirm.value !== password.value) e.confirm = '两次输入的密码不一致'
|
||||
errors.value = e
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
formError.value = ''
|
||||
if (!validate()) return
|
||||
if (!username.value || !email.value) {
|
||||
error.value = '请填写用户名和邮箱'
|
||||
return
|
||||
}
|
||||
if (password.value.length < 8) {
|
||||
error.value = '密码至少 8 位'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await auth.register(username.value.trim(), email.value.trim(), password.value)
|
||||
await auth.login(username.value.trim(), password.value)
|
||||
await auth.register(username.value, email.value, password.value)
|
||||
await auth.login(username.value, password.value)
|
||||
toast.ok('注册成功,欢迎使用')
|
||||
router.push('/console/dashboard')
|
||||
} catch (e: any) {
|
||||
formError.value = e.response?.data?.error?.message || '注册失败,请稍后重试'
|
||||
} catch (e) {
|
||||
error.value = errMsg(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -46,71 +42,33 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
|
||||
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-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" aria-hidden="true">
|
||||
<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" translate="no">openteam</span>
|
||||
<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>
|
||||
</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" novalidate>
|
||||
<Input
|
||||
v-model="username"
|
||||
label="用户名"
|
||||
name="username"
|
||||
placeholder="alice"
|
||||
autocomplete="username"
|
||||
:spellcheck="false"
|
||||
:error="errors.username"
|
||||
autofocus
|
||||
/>
|
||||
<Input
|
||||
v-model="email"
|
||||
label="邮箱"
|
||||
name="email"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
placeholder="alice@example.com"
|
||||
autocomplete="email"
|
||||
:spellcheck="false"
|
||||
:error="errors.email"
|
||||
/>
|
||||
<form class="card space-y-4 p-6" @submit.prevent="submit">
|
||||
<Input v-model="username" label="用户名" autocomplete="username" placeholder="alice" />
|
||||
<Input v-model="email" label="邮箱" type="email" autocomplete="email" placeholder="alice@example.com" />
|
||||
<Input
|
||||
v-model="password"
|
||||
label="密码"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="至少 8 位…"
|
||||
autocomplete="new-password"
|
||||
hint="使用 argon2id 加密存储"
|
||||
:error="errors.password"
|
||||
hint="至少 8 位"
|
||||
/>
|
||||
<Input
|
||||
v-model="confirm"
|
||||
label="确认密码"
|
||||
name="confirm"
|
||||
type="password"
|
||||
placeholder="再次输入…"
|
||||
autocomplete="new-password"
|
||||
:error="errors.confirm"
|
||||
/>
|
||||
<p
|
||||
v-if="formError"
|
||||
class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>{{ formError }}</p>
|
||||
<Button type="submit" class="w-full" :loading="loading">注册</Button>
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
<Button class="w-full" :loading="loading" type="submit">注册</Button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-[13px] text-paper-500">
|
||||
已有账号?
|
||||
<router-link to="/login" class="text-signal-300 transition-colors hover:text-signal-200">登录</router-link>
|
||||
<p class="mt-5 text-center text-sm text-zinc-500">
|
||||
已有账号?
|
||||
<router-link to="/login" class="text-accent hover:text-accent-strong">登录</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ShellLayout from '@/components/layout/ShellLayout.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const sections = computed(() => {
|
||||
const s: { title: string; items: { to: string; label: string }[] }[] = [
|
||||
{ title: '管理', items: [
|
||||
{ to: '/admin/overview', label: '运营总览' },
|
||||
{ to: '/admin/channels', label: '渠道' },
|
||||
{ to: '/admin/models', label: '模型与定价' },
|
||||
{ to: '/admin/users', label: '用户' },
|
||||
{ to: '/admin/config', label: '系统配置' },
|
||||
] },
|
||||
]
|
||||
if (auth.user) {
|
||||
s.push({ title: '控制台', items: [
|
||||
{ to: '/console/dashboard', label: '仪表盘' },
|
||||
{ to: '/console/keys', label: 'API Keys' },
|
||||
{ to: '/console/usage', label: '用量' },
|
||||
] })
|
||||
}
|
||||
return s
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ShellLayout :sections="sections">
|
||||
<router-view />
|
||||
</ShellLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const channels = ref<Channel[]>([])
|
||||
const editOpen = ref(false)
|
||||
const editing = ref<Channel | null>(null)
|
||||
const saving = ref(false)
|
||||
const busyId = ref<number | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
provider: 'openai' as 'openai' | 'anthropic' | 'compatible',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
weight: 1,
|
||||
priority: 0,
|
||||
timeout_ms: 120000,
|
||||
max_concurrency: 16,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const providerMap: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
compatible: '兼容',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await http.get('/admin/channels')
|
||||
channels.value = data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', provider: 'openai', base_url: '', api_key: '',
|
||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(ch: Channel) {
|
||||
editing.value = ch
|
||||
Object.assign(form, {
|
||||
name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '',
|
||||
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
|
||||
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
const payload = {
|
||||
...form,
|
||||
weight: Number(form.weight),
|
||||
priority: Number(form.priority),
|
||||
timeout_ms: Number(form.timeout_ms),
|
||||
max_concurrency: Number(form.max_concurrency),
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await http.put(`/admin/channels/${editing.value.id}`, payload)
|
||||
toast.ok('渠道已更新')
|
||||
} else {
|
||||
await http.post('/admin/channels', payload)
|
||||
toast.ok('渠道已创建')
|
||||
}
|
||||
editOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(ch: Channel) {
|
||||
if (!confirm(`删除渠道 ${ch.name}?关联的模型绑定也会清除。`)) return
|
||||
try {
|
||||
await http.delete(`/admin/channels/${ch.id}`)
|
||||
toast.ok('渠道已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel(ch: Channel) {
|
||||
busyId.value = ch.id
|
||||
try {
|
||||
await http.post(`/admin/channels/${ch.id}/test`)
|
||||
toast.ok(`渠道 ${ch.name} 连接正常`)
|
||||
} catch (e) {
|
||||
toast.err(`连接失败: ${errMsg(e)}`)
|
||||
} finally {
|
||||
busyId.value = null
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
async function importModels(ch: Channel) {
|
||||
busyId.value = ch.id
|
||||
try {
|
||||
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
|
||||
toast.ok(`已导入 ${data.data.imported} 个模型`)
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<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>
|
||||
</div>
|
||||
<Button @click="openCreate">添加渠道</Button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<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">
|
||||
<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>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">Key</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">启用</th>
|
||||
<th scope="col" class="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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">
|
||||
<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-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)">
|
||||
{{ 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>
|
||||
</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="compatible">兼容</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" />
|
||||
<Input
|
||||
v-model="form.api_key"
|
||||
label="上游 API Key"
|
||||
:placeholder="editing ? '留空则不修改' : 'sk-...'"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input v-model="form.weight" label="权重" type="number" />
|
||||
<Input v-model="form.priority" label="优先级" type="number" />
|
||||
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
||||
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const toast = useToastStore()
|
||||
const config = reactive<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await http.get('/admin/config')
|
||||
Object.keys(config).forEach((k) => delete config[k])
|
||||
Object.assign(config, data.data.config)
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await http.put('/admin/config', config)
|
||||
toast.ok('配置已保存')
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card space-y-5 p-6">
|
||||
<div v-if="loading" class="py-8 text-center text-sm text-zinc-600">加载中…</div>
|
||||
<template v-else>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium text-zinc-400">注册模式</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'"
|
||||
@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'"
|
||||
@click="config.registration_mode = 'invite'"
|
||||
>
|
||||
邀请码
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-zinc-600">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium text-zinc-400">邀请码(逗号分隔)</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900/60 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm text-zinc-300">其他配置项</p>
|
||||
<p class="text-xs text-zinc-600">汇率、限流阈值、维护开关在后续里程碑开放</p>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-zinc-600">M3+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :loading="saving" @click="save">保存</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import type { Channel, Model } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const models = ref<Model[]>([])
|
||||
const channels = ref<Channel[]>([])
|
||||
const editOpen = ref(false)
|
||||
const editing = ref<Model | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const bindOpen = ref(false)
|
||||
const bindModel = ref<Model | null>(null)
|
||||
const binding = reactive({ channel_id: 0, upstream_model: '', weight: 1 })
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
display_name: '',
|
||||
input_price: 0,
|
||||
output_price: 0,
|
||||
cache_read_price: 0,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
||||
models.value = m.data.data.items
|
||||
channels.value = c.data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', display_name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(m: Model) {
|
||||
editing.value = m
|
||||
Object.assign(form, {
|
||||
name: m.name, display_name: m.display_name,
|
||||
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
|
||||
enabled: m.enabled,
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
const payload = {
|
||||
display_name: form.display_name || form.name,
|
||||
input_price: Number(form.input_price),
|
||||
output_price: Number(form.output_price),
|
||||
cache_read_price: Number(form.cache_read_price),
|
||||
enabled: form.enabled,
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await http.put(`/admin/models/${editing.value.id}`, payload)
|
||||
toast.ok('模型已更新')
|
||||
} else {
|
||||
await http.post('/admin/models', { name: form.name, ...payload })
|
||||
toast.ok('模型已创建')
|
||||
}
|
||||
editOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeModel(m: Model) {
|
||||
if (!confirm(`删除模型 ${m.name}?`)) return
|
||||
try {
|
||||
await http.delete(`/admin/models/${m.id}`)
|
||||
toast.ok('模型已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openBind(m: Model) {
|
||||
bindModel.value = m
|
||||
Object.assign(binding, { channel_id: channels.value[0]?.id ?? 0, upstream_model: m.name, weight: 1 })
|
||||
bindOpen.value = true
|
||||
}
|
||||
|
||||
async function saveBinding() {
|
||||
if (!bindModel.value) return
|
||||
try {
|
||||
await http.post(`/admin/models/${bindModel.value.id}/bindings`, {
|
||||
...binding,
|
||||
weight: Number(binding.weight),
|
||||
})
|
||||
toast.ok('绑定已添加')
|
||||
bindOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBinding(m: Model, bid: number) {
|
||||
try {
|
||||
await http.delete(`/admin/models/${m.id}/bindings/${bid}`)
|
||||
toast.ok('绑定已移除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<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>
|
||||
</div>
|
||||
<Button @click="openCreate">添加模型</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="m.channels.length" class="border-t border-zinc-800/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"
|
||||
>
|
||||
{{ b.channel_name }} → {{ b.upstream_model }}
|
||||
<button class="text-zinc-400 hover:text-red-400" 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>
|
||||
</div>
|
||||
|
||||
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-zinc-600">
|
||||
还没有模型,点击「添加模型」或到渠道页「导入模型」
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型编辑 -->
|
||||
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
||||
<Input v-model="form.display_name" label="展示名" />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
||||
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
||||
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 绑定渠道 -->
|
||||
<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>
|
||||
<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">
|
||||
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Input v-model="binding.upstream_model" label="上游模型名" placeholder="与渠道侧一致" />
|
||||
<Input v-model="binding.weight" label="权重" type="number" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="bindOpen = false">取消</Button>
|
||||
<Button @click="saveBinding">绑定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const data = ref({
|
||||
total_users: 0, total_keys: 0, total_channels: 0, total_models: 0,
|
||||
today: { requests: 0, cost: 0, tokens: 0 },
|
||||
month: { requests: 0, cost: 0, tokens: 0 },
|
||||
trend_14d: [] as { date: string; requests: number; cost: number }[],
|
||||
})
|
||||
const logs = ref<UsageLog[]>([])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [o, u] = await Promise.all([http.get('/admin/stats/overview'), http.get('/admin/usage?page_size=8')])
|
||||
data.value = o.data.data
|
||||
logs.value = u.data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</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(data.total_users) }} / {{ fmtNum(data.total_keys) }}</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(data.total_channels) }} / {{ fmtNum(data.total_models) }}</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(data.today.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-emerald-300">{{ fmtCost(data.month.cost) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-5">
|
||||
<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>
|
||||
</div>
|
||||
<TrendChart
|
||||
:points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))"
|
||||
:format="(v) => '$' + v.toExponential(2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<ul class="divide-y divide-zinc-800/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>
|
||||
<p class="font-mono text-[11px] text-zinc-600">{{ 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>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fmtMoney, fmtTime } from '@/lib/format'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const auth = useAuthStore()
|
||||
const users = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const q = ref('')
|
||||
const pageSize = 15
|
||||
|
||||
const editOpen = ref(false)
|
||||
const editing = ref<User | null>(null)
|
||||
const editForm = reactive({ role: 'user', status: 'active' })
|
||||
|
||||
const balanceOpen = ref(false)
|
||||
const balanceUser = ref<User | null>(null)
|
||||
const balanceForm = reactive({ amount: 0, remark: '' })
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + q.value : ''}`)
|
||||
users.value = data.data.items
|
||||
total.value = data.data.total
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openEdit(u: User) {
|
||||
editing.value = u
|
||||
Object.assign(editForm, { role: u.role, status: u.status })
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing.value) return
|
||||
try {
|
||||
await http.patch(`/admin/users/${editing.value.id}`, editForm)
|
||||
toast.ok('已更新')
|
||||
editOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openBalance(u: User) {
|
||||
balanceUser.value = u
|
||||
Object.assign(balanceForm, { amount: 0, remark: '' })
|
||||
balanceOpen.value = true
|
||||
}
|
||||
|
||||
async function saveBalance() {
|
||||
if (!balanceUser.value || !balanceForm.amount) return
|
||||
try {
|
||||
await http.post(`/admin/users/${balanceUser.value.id}/balance`, {
|
||||
amount: Number(balanceForm.amount),
|
||||
remark: balanceForm.remark,
|
||||
})
|
||||
toast.ok('余额已调整')
|
||||
balanceOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
function goPage(p: number) {
|
||||
page.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<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>
|
||||
</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"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<Button variant="ghost" @click="search">搜索</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">注册时间</th>
|
||||
<th scope="col" class="px-4 py-2.5" />
|
||||
</tr>
|
||||
</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">
|
||||
{{ u.username }}
|
||||
<span v-if="u.id === auth.user?.id" class="text-[11px] text-zinc-600">(我)</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ 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">
|
||||
<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 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>
|
||||
</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>
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户 -->
|
||||
<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">
|
||||
<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">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||
<Button @click="saveEdit">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 调整余额 -->
|
||||
<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>
|
||||
<Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" />
|
||||
<Input v-model="balanceForm.remark" label="备注" placeholder="可选" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="balanceOpen = false">取消</Button>
|
||||
<Button @click="saveBalance">确认</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,85 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import Toast from '../../components/ui/Toast.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ShellLayout from '@/components/layout/ShellLayout.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(() => {
|
||||
if (!auth.user) return '—'
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(auth.user.balance)
|
||||
const sections = computed(() => {
|
||||
const s: { title: string; items: { to: string; label: string }[] }[] = [
|
||||
{
|
||||
title: '控制台',
|
||||
items: [
|
||||
{ to: '/console/dashboard', label: '仪表盘' },
|
||||
{ to: '/console/keys', label: 'API Keys' },
|
||||
{ to: '/console/usage', label: '用量' },
|
||||
],
|
||||
},
|
||||
]
|
||||
if (auth.isAdmin) {
|
||||
s.push({
|
||||
title: '管理',
|
||||
items: [
|
||||
{ to: '/admin/overview', label: '运营总览' },
|
||||
{ to: '/admin/channels', label: '渠道' },
|
||||
{ to: '/admin/models', label: '模型与定价' },
|
||||
{ to: '/admin/users', label: '用户' },
|
||||
{ to: '/admin/config', label: '系统配置' },
|
||||
],
|
||||
})
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[100dvh] bg-ink-950 text-paper-100">
|
||||
<!-- 无障碍:跳过导航 -->
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[70] focus:rounded-md focus:bg-signal-400 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-ink-950"
|
||||
>跳到主内容</a>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside aria-label="主导航" 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" aria-hidden="true">
|
||||
<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" translate="no">openteam</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 space-y-0.5 px-3 py-4" aria-label="控制台">
|
||||
<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 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
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" aria-hidden="true"><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" translate="no">{{ balanceFmt }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="mt-2 flex w-full touch-manipulation 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 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none cursor-pointer"
|
||||
@click="logout"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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">
|
||||
<main id="main-content" class="mx-auto max-w-6xl scroll-mt-4 px-8 py-8" tabindex="-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 全局通知 -->
|
||||
<Toast />
|
||||
</div>
|
||||
<ShellLayout :sections="sections">
|
||||
<router-view />
|
||||
</ShellLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,167 +1,109 @@
|
||||
<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'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
|
||||
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
||||
const toast = useToastStore()
|
||||
|
||||
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(0)
|
||||
const today = ref({ requests: 0, tokens: 0, cost: 0 })
|
||||
const monthCost = ref(0)
|
||||
const models = ref(0)
|
||||
const trend = ref<{ label: string; value: number }[]>([])
|
||||
const logs = ref<UsageLog[]>([])
|
||||
|
||||
const balance = ref<BalanceInfo | null>(null)
|
||||
const stats = ref<UsagePoint[]>([])
|
||||
const recentLogs = ref<LogItem[]>([])
|
||||
const error = ref('')
|
||||
const loaded = ref(false)
|
||||
|
||||
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
||||
const usdPrecise = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 6, maximumFractionDigits: 6 })
|
||||
|
||||
const balanceText = computed(() => (balance.value ? usd.format(balance.value.balance) : '—'))
|
||||
const spent30d = computed(() => usd.format(balance.value?.spent_last_30d ?? 0))
|
||||
const todayCostText = computed(() => usdPrecise.format(balance.value?.today.cost ?? 0))
|
||||
const modelsCount = computed(() => balance.value?.models_available ?? '—')
|
||||
const todayRequests = computed(() => balance.value?.today.requests ?? '—')
|
||||
const todayTokens = computed(() => balance.value?.today.tokens ?? 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 () => {
|
||||
async function load() {
|
||||
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 } })),
|
||||
const [b, s, l] = await Promise.all([
|
||||
http.get('/user/balance'),
|
||||
http.get('/usage/stats?group=day&from=' + daysAgo(13)),
|
||||
http.get('/usage/logs?page_size=8'),
|
||||
])
|
||||
balance.value = b
|
||||
stats.value = s.items ?? []
|
||||
recentLogs.value = logs.items ?? []
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loaded.value = true
|
||||
const d = b.data.data
|
||||
balance.value = d.balance
|
||||
today.value = d.today
|
||||
monthCost.value = d.spent_last_30d
|
||||
models.value = d.models_available
|
||||
trend.value = s.data.data.items.map((x: { date: string; cost: number }) => ({ label: x.date.slice(5), value: x.cost }))
|
||||
logs.value = l.data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fmtCost = (n: number) => usd.format(n)
|
||||
function daysAgo(n: number): string {
|
||||
const d = new Date(Date.now() - n * 864e5)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</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="inline-flex h-10 touch-manipulation items-center rounded-md bg-signal-400 px-4 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
>新建密钥</router-link>
|
||||
<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>
|
||||
</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" role="alert">{{ error }}</p>
|
||||
<!-- 指标条 -->
|
||||
<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-emerald-300">{{ fmtMoney(balance) }}</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(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(today.tokens) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">近 30 日消耗</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指标行 -->
|
||||
<section aria-label="用量指标" class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<template v-if="!loaded">
|
||||
<div v-for="i in 4" :key="i" class="rounded-lg border border-ink-700 bg-ink-900 p-4" aria-hidden="true">
|
||||
<div class="h-3 w-14 animate-pulse rounded bg-ink-700" />
|
||||
<div class="mt-3 h-7 w-24 animate-pulse rounded bg-ink-700" />
|
||||
<div class="mt-2 h-3 w-20 animate-pulse rounded bg-ink-700" />
|
||||
<div class="grid gap-6 lg:grid-cols-5">
|
||||
<!-- 用量趋势 -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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" translate="no">{{ balanceText }}</p>
|
||||
<p class="num mt-1 text-[11px] text-paper-600">30 日消耗 <span translate="no">{{ spent30d }}</span></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">{{ todayRequests }}</p>
|
||||
<p class="num mt-1 text-[11px] text-paper-600">{{ todayTokens }} 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" translate="no">{{ todayCostText }}</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">{{ modelsCount }}</p>
|
||||
<p class="mt-1 text-[11px] text-paper-600">GET /v1/models 查看</p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- 图表 + 最近请求 -->
|
||||
<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" translate="no">usage/stats?group=day</span>
|
||||
</div>
|
||||
<VChart v-if="stats.length" class="h-56" :option="chartOption" autoresize />
|
||||
<div v-else-if="loaded" class="flex h-56 items-center justify-center text-[13px] text-paper-600">暂无数据,发起第一次请求后这里会出现图表</div>
|
||||
<div v-else class="h-56 animate-pulse rounded bg-ink-800" aria-hidden="true" />
|
||||
<TrendChart :points="trend" :format="(v) => '$' + v.toExponential(2)" />
|
||||
</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 transition-colors hover:text-signal-200">全部 →</router-link>
|
||||
<!-- 最近请求 -->
|
||||
<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>
|
||||
<router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</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">
|
||||
<ul class="divide-y divide-zinc-800/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-[12.5px] text-paper-100" translate="no">{{ l.model }}</p>
|
||||
<p class="num mt-0.5 text-[11px] text-paper-600" translate="no">{{ l.protocol }} · {{ l.input_tokens }}/{{ l.output_tokens }} tok · {{ l.latency_ms }}ms</p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="num text-[12.5px] text-paper-300" translate="no">{{ fmtCost(l.cost) }}</span>
|
||||
<Badge :tone="l.status === 'success' ? 'success' : 'error'" />
|
||||
<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>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loaded" class="flex h-48 items-center justify-center text-[13px] text-paper-600">还没有请求记录</div>
|
||||
<div v-else class="space-y-3 pt-2" aria-hidden="true">
|
||||
<div v-for="i in 4" :key="i" class="h-8 animate-pulse rounded bg-ink-800" />
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">
|
||||
还没有请求记录,去
|
||||
<router-link to="/console/keys" class="text-accent">API Keys</router-link>
|
||||
创建密钥开始调用
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+107
-158
@@ -1,205 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } 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'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
|
||||
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
|
||||
}
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtTime } from '@/lib/format'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import type { ApiKey } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const keys = ref<ApiKey[]>([])
|
||||
|
||||
const keys = ref<APIKey[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 创建
|
||||
const showCreate = ref(false)
|
||||
const newName = ref('')
|
||||
const createOpen = ref(false)
|
||||
const keyName = ref('')
|
||||
const creating = ref(false)
|
||||
const createdKey = ref('')
|
||||
const createError = ref('')
|
||||
const nameInput = ref<{ focus: () => void } | null>(null)
|
||||
|
||||
// 吊销
|
||||
const revokeTarget = ref<APIKey | null>(null)
|
||||
const revoking = ref(false)
|
||||
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
|
||||
const copied = 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
|
||||
const { data } = await http.get('/keys')
|
||||
keys.value = data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
createError.value = ''
|
||||
if (!newName.value.trim()) {
|
||||
createError.value = '请填写密钥名称'
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
return
|
||||
}
|
||||
async function createKey() {
|
||||
if (!keyName.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 = ''
|
||||
const { data } = await http.post('/keys', { name: keyName.value })
|
||||
created.value = data.data
|
||||
createOpen.value = false
|
||||
keyName.value = ''
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
createError.value = e.response?.data?.error?.message || '创建失败'
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!revokeTarget.value) return
|
||||
revoking.value = true
|
||||
async function revoke(k: ApiKey) {
|
||||
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
||||
try {
|
||||
await unwrap(client.delete(`/keys/${revokeTarget.value.id}`))
|
||||
toast.success(`密钥 ${revokeTarget.value.key_prefix}… 已吊销`)
|
||||
revokeTarget.value = null
|
||||
await http.delete(`/keys/${k.id}`)
|
||||
toast.ok('密钥已吊销')
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '吊销失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey(text: string) {
|
||||
async function copyKey() {
|
||||
if (!created.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
await navigator.clipboard.writeText(created.value.key)
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 1500)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
ta.remove()
|
||||
toast.err('复制失败,请手动复制')
|
||||
}
|
||||
toast.success('密钥已复制')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const fmtDate = (s: string | null) =>
|
||||
s ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(s)) : '从未使用'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="mb-6 flex items-center 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>
|
||||
<h1 class="text-lg font-semibold">API Keys</h1>
|
||||
<p class="text-sm text-zinc-500">密钥明文仅在创建时展示一次,请立即保存</p>
|
||||
</div>
|
||||
<Button @click="showCreate = true">新建密钥</Button>
|
||||
<Button @click="createOpen = 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" role="alert">{{ error }}</p>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
|
||||
<table class="w-full text-left text-[13px]">
|
||||
<caption class="sr-only">API 密钥列表</caption>
|
||||
<thead>
|
||||
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
||||
<th scope="col" class="px-4 py-3 font-medium">名称</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">密钥前缀</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">每日限额</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">最近使用</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">状态</th>
|
||||
<th scope="col" 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" translate="no">{{ k.key_prefix }}…</code></td>
|
||||
<td class="num px-4 py-3 text-paper-500" translate="no">
|
||||
{{ 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" translate="no">{{ 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="touch-manipulation text-xs text-ember-400 transition-colors hover:text-ember-300 focus-visible:ring-2 focus-visible:ring-ember-400/60 focus:outline-none 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">
|
||||
{{ loading ? '加载中…' : '还没有密钥 — 点击右上角「新建密钥」创建第一个' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card">
|
||||
<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">
|
||||
<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>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">最近使用</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">创建时间</th>
|
||||
<th scope="col" class="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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">
|
||||
<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 text-right">
|
||||
<button
|
||||
v-if="k.status === 'active'"
|
||||
class="text-xs text-zinc-500 hover:text-red-400"
|
||||
@click="revoke(k)"
|
||||
>
|
||||
吊销
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="keys.length === 0">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-600">
|
||||
还没有密钥,点击右上角「新建密钥」
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建模态 -->
|
||||
<Modal :open="showCreate" title="新建 API 密钥" @close="showCreate = false">
|
||||
<template v-if="!createdKey">
|
||||
<Input
|
||||
ref="nameInput"
|
||||
v-model="newName"
|
||||
label="密钥名称"
|
||||
name="key-name"
|
||||
placeholder="例如:本地开发"
|
||||
hint="用于在用量明细中区分来源"
|
||||
:error="createError || undefined"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
@keydown.enter.prevent="create"
|
||||
/>
|
||||
<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="min-w-0 flex-1 break-all font-mono text-[12.5px] text-mint-300" translate="no">{{ createdKey }}</code>
|
||||
<Button variant="outline" size="sm" @click="copyKey(createdKey)">复制</Button>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button @click="showCreate = false; createdKey = ''">完成</Button>
|
||||
</div>
|
||||
<!-- 新建密钥 -->
|
||||
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
|
||||
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="createOpen = false">取消</Button>
|
||||
<Button :loading="creating" @click="createKey">创建</Button>
|
||||
</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" translate="no">{{ 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>
|
||||
<!-- 一次性展示密钥 -->
|
||||
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-zinc-500">请复制并妥善保存,关闭后不再显示。</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">
|
||||
{{ created?.key }}
|
||||
</code>
|
||||
<Button size="sm" @click="copyKey">
|
||||
<PhCheck v-if="copied" :size="14" />
|
||||
<PhCopy v-else :size="14" />
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="created = null">完成</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+126
-128
@@ -1,149 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import client, { unwrap } from '../../api/client'
|
||||
import Badge from '../../components/ui/Badge.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
|
||||
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 route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const logs = ref<LogItem[]>([])
|
||||
const toast = useToastStore()
|
||||
const summary = ref({ today: { requests: 0, tokens: 0, cost: 0 }, month: { requests: 0, tokens: 0, cost: 0 } })
|
||||
const group = ref<'day' | 'model'>('day')
|
||||
const chart = ref<{ label: string; value: number }[]>([])
|
||||
const logs = ref<UsageLog[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const page = ref(Number(route.query.page) || 1)
|
||||
const pageSize = 20
|
||||
const modelFilter = ref((route.query.model as string) || '')
|
||||
const models = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
||||
const fmtDate = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
// 筛选/分页同步到 URL(可深链、可分享)
|
||||
watch([page, modelFilter], () => {
|
||||
router.replace({
|
||||
query: {
|
||||
...(modelFilter.value ? { model: modelFilter.value } : {}),
|
||||
...(page.value > 1 ? { page: String(page.value) } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
const modelFilter = ref('')
|
||||
const pageSize = 15
|
||||
|
||||
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
|
||||
} catch {
|
||||
// 加载失败静默,空态兜底
|
||||
} finally {
|
||||
loading.value = false
|
||||
const [s, st, l] = await Promise.all([
|
||||
http.get('/usage/summary'),
|
||||
http.get(`/usage/stats?group=${group.value}`),
|
||||
http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`),
|
||||
])
|
||||
summary.value = s.data.data
|
||||
const items = st.data.data.items as { date?: string; model?: string; cost: number; requests: number }[]
|
||||
chart.value = items.map((x) => ({ label: (x.date || x.model || '') as string, value: x.cost }))
|
||||
logs.value = l.data.data.items
|
||||
total.value = l.data.data.total
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const data = await unwrap<{ items: string[] }>(client.get('/user/models'))
|
||||
models.value = data.items ?? []
|
||||
} catch { /* ignore */ }
|
||||
function switchGroup(g: 'day' | 'model') {
|
||||
group.value = g
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function goPage(p: number) {
|
||||
page.value = p
|
||||
load()
|
||||
loadModels()
|
||||
})
|
||||
}
|
||||
|
||||
const fmtCost = (n: number) => usd.format(n)
|
||||
const fmtDateStr = (s: string) => fmtDate.format(new Date(s))
|
||||
onMounted(load)
|
||||
</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 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>
|
||||
</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>
|
||||
</div>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-xs text-paper-600">模型</span>
|
||||
<select
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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">
|
||||
<button
|
||||
class="rounded px-2.5 py-1 text-xs transition"
|
||||
:class="group === 'day' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
|
||||
@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'"
|
||||
@click="switchGroup('model')"
|
||||
>
|
||||
按模型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TrendChart :points="chart" :format="(v) => '$' + v.toExponential(2)" />
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||
<h2 class="text-sm font-semibold">请求明细</h2>
|
||||
<input
|
||||
v-model="modelFilter"
|
||||
class="h-9 touch-manipulation rounded-md border border-ink-600 bg-ink-900 px-3 text-[13px] text-paper-300 transition-colors hover:border-ink-700 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
@change="page = 1; load()"
|
||||
>
|
||||
<option value="">全部模型</option>
|
||||
<option v-for="m in models" :key="m" :value="m" translate="no">{{ m }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
|
||||
<table class="w-full text-left text-[13px]">
|
||||
<caption class="sr-only">用量明细记录</caption>
|
||||
<thead>
|
||||
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
||||
<th scope="col" class="px-4 py-3 font-medium">时间</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">模型</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium">协议</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-medium">输入 tok</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-medium">输出 tok</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-medium">成本</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-medium">耗时</th>
|
||||
<th scope="col" 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" translate="no">{{ fmtDateStr(l.created_at) }}</td>
|
||||
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-paper-100" translate="no">{{ l.model }}</code></td>
|
||||
<td class="px-4 py-3 font-mono text-[12px] text-paper-500" translate="no">{{ l.protocol }}</td>
|
||||
<td class="num px-4 py-3 text-right text-paper-300" translate="no">{{ l.input_tokens }}</td>
|
||||
<td class="num px-4 py-3 text-right text-paper-300" translate="no">{{ l.output_tokens }}</td>
|
||||
<td class="num px-4 py-3 text-right text-signal-300" translate="no">{{ fmtCost(l.cost) }}</td>
|
||||
<td class="num px-4 py-3 text-right text-paper-500" translate="no">{{ 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>
|
||||
<nav aria-label="分页" class="flex items-center gap-1.5">
|
||||
<button
|
||||
class="touch-manipulation 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 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
:disabled="page <= 1"
|
||||
:aria-label="`上一页,当前第 ${page} 页`"
|
||||
@click="page--; load()"
|
||||
>上一页</button>
|
||||
<span class="num px-2 text-xs text-paper-500" aria-current="page">第 {{ page }} / {{ pages }} 页</span>
|
||||
<button
|
||||
class="touch-manipulation 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 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
|
||||
:disabled="page >= pages"
|
||||
:aria-label="`下一页,当前第 ${page} 页`"
|
||||
@click="page++; load()"
|
||||
>下一页</button>
|
||||
</nav>
|
||||
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"
|
||||
@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">
|
||||
<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>
|
||||
<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>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">时间</th>
|
||||
</tr>
|
||||
</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"><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>
|
||||
</tr>
|
||||
<tr v-if="logs.length === 0">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">暂无请求记录</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Vendored
+6
@@ -1 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user