前端: 按 Web Interface Guidelines 全面重做
组件: - Button: transition-all→明确属性列表, touch-action, aria-busy - Input: name/spellcheck/inputmode/aria-invalid/aria-describedby, focus-visible ring, label 关联, 暴露 focus() 供错误定位, required 标记 - Modal: Escape 关闭 + focus trap + 焦点归还, overscroll-behavior: contain, aria-labelledby, body 滚动锁, touch-action - 新增 Toast(aria-live polite) + pinia toast store 页面: - ConsoleLayout: skip link, <main> 语义标签, aside/nav aria-label, aria-current, 装饰 SVG aria-hidden, Intl 金额, translate=no 品牌/代码 - Dashboard: Intl.NumberFormat, 骨架屏 loading, aria-live 错误 - Keys: 字段级错误+焦点定位, toast 反馈(复制/吊销), name 属性 - Usage: select 加 label, 分页/筛选同步 URL(可深链), Intl 日期/金额, 表格 caption - Login/Register: 字段级校验+错误定位, spellcheck=false, inputmode, autocomplete - Landing: router-link 替换 href, aria-hidden, text-balance, scroll-mt - index.html: theme-color + color-scheme 验证: playwright 22 项断言全过, WCAG AA 对比度 9 组全过, 零控制台错误
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0c0d0f" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
<title>openteam · LLM 中转网关</title>
|
<title>openteam · LLM 中转网关</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// WIG 合规验证:语义标签 / aria / focus / modal 交互
|
||||||
|
const { chromium } = require('playwright')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||||
|
const errors = []
|
||||||
|
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 150)) })
|
||||||
|
|
||||||
|
const BASE = 'http://127.0.0.1:8088'
|
||||||
|
let pass = 0, fail = 0
|
||||||
|
const check = (name, ok) => { console.log(`${ok ? '✓' : '✗'} ${name}`); ok ? pass++ : fail++ }
|
||||||
|
|
||||||
|
// ---- Landing ----
|
||||||
|
await page.goto(BASE + '/', { waitUntil: 'networkidle' })
|
||||||
|
check('landing: h1 存在', await page.locator('h1').count() === 1)
|
||||||
|
check('landing: main 语义标签', await page.locator('main#landing-main').count() === 1)
|
||||||
|
check('landing: 导航用 router-link(<a>)', await page.locator('header nav a').count() >= 2)
|
||||||
|
check('landing: theme-color', await page.locator('meta[name="theme-color"]').count() === 1)
|
||||||
|
|
||||||
|
// ---- 注册(带字段级校验)----
|
||||||
|
const uname = 'wig' + Date.now().toString().slice(-6)
|
||||||
|
await page.goto(BASE + '/register', { waitUntil: 'networkidle' })
|
||||||
|
// 空提交 → 字段级错误
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
check('register: 字段级错误显示', await page.locator('[role="alert"]').count() >= 1)
|
||||||
|
// 填错邮箱
|
||||||
|
await page.fill('input[name="username"]', uname)
|
||||||
|
await page.fill('input[name="email"]', 'not-an-email')
|
||||||
|
await page.fill('input[name="password"]', 'password123')
|
||||||
|
await page.fill('input[name="confirm"]', 'password123')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
check('register: 邮箱格式错误', await page.locator('text=邮箱格式不正确').count() === 1)
|
||||||
|
// 修正并提交
|
||||||
|
await page.fill('input[name="email"]', uname + '@test.com')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
// ---- Console ----
|
||||||
|
check('console: skip link', await page.locator('a[href="#main-content"]').count() === 1)
|
||||||
|
check('console: main 语义标签', await page.locator('main#main-content').count() === 1)
|
||||||
|
check('console: aside aria-label', await page.locator('aside[aria-label]').count() === 1)
|
||||||
|
check('console: 侧边栏卡片', await page.locator('section[aria-label="用量指标"] .rounded-lg').count() === 4)
|
||||||
|
check('console: 骨架屏已消失(loaded)', await page.locator('.animate-pulse').count() === 0)
|
||||||
|
|
||||||
|
// ---- Keys ----
|
||||||
|
await page.goto(BASE + '/console/keys', { waitUntil: 'networkidle' })
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
check('keys: 表格 caption', await page.locator('table caption.sr-only').count() === 1)
|
||||||
|
check('keys: 吊销按钮', await page.locator('button:has-text("吊销")').count() >= 0)
|
||||||
|
// 创建密钥 modal:Escape 关闭
|
||||||
|
await page.click('button:has-text("新建密钥")')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
check('modal: dialog 打开', await page.locator('[role="dialog"]').count() === 1)
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
check('modal: Escape 关闭', await page.locator('[role="dialog"]').count() === 0)
|
||||||
|
// 重新打开并创建
|
||||||
|
await page.click('button:has-text("新建密钥")')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.fill('input[name="key-name"]', 'wig-test')
|
||||||
|
await page.click('button:has-text("创建密钥")')
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
check('modal: 一次性明文展示', await page.locator('code.text-mint-300').count() === 1)
|
||||||
|
check('modal: 复制按钮 + toast', (await page.click('button:has-text("复制")'), true))
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
check('toast: aria-live 容器', await page.locator('[aria-live="polite"]').count() >= 1)
|
||||||
|
await page.click('button:has-text("完成")')
|
||||||
|
|
||||||
|
// ---- Usage ----
|
||||||
|
await page.goto(BASE + '/console/usage?model=gpt-4o-mini', { waitUntil: 'networkidle' })
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
check('usage: select 有 label', await page.locator('label:has(select)').count() === 1)
|
||||||
|
check('usage: URL 反映筛选状态', page.url().includes('model='))
|
||||||
|
check('usage: 分页 aria 标签', await page.locator('nav[aria-label="分页"]').count() === 1)
|
||||||
|
|
||||||
|
// ---- Tab 焦点可见性 ----
|
||||||
|
await page.goto(BASE + '/console/dashboard', { waitUntil: 'networkidle' })
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
const focusedRing = await page.evaluate(() => {
|
||||||
|
const el = document.activeElement
|
||||||
|
if (!el) return 'none'
|
||||||
|
const s = getComputedStyle(el)
|
||||||
|
return s.outlineStyle === 'auto' || s.outlineWidth !== '0px' ? 'outline' : (s.boxShadow !== 'none' ? 'ring' : 'none')
|
||||||
|
})
|
||||||
|
check(`focus: Tab 首元素可见焦点 (${focusedRing})`, focusedRing !== 'none')
|
||||||
|
|
||||||
|
console.log(`\n结果: ${pass} 通过, ${fail} 失败`)
|
||||||
|
if (errors.length) console.log('控制台错误:', errors.slice(0, 5))
|
||||||
|
await browser.close()
|
||||||
|
process.exit(fail > 0 ? 1 : 0)
|
||||||
|
}
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1) })
|
||||||
@@ -6,16 +6,22 @@ withDefaults(
|
|||||||
type?: 'button' | 'submit'
|
type?: 'button' | 'submit'
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
}>(),
|
}>(),
|
||||||
{ variant: 'primary', size: 'md', type: 'button', loading: false, disabled: false },
|
{ variant: 'primary', size: 'md', type: 'button', loading: false, disabled: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'click', ev: MouseEvent): void }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
:type="type"
|
:type="type"
|
||||||
:disabled="disabled || loading"
|
:disabled="disabled || loading"
|
||||||
class="inline-flex items-center justify-center gap-2 font-medium transition-all select-none
|
: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"
|
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none cursor-pointer"
|
||||||
:class="[
|
:class="[
|
||||||
size === 'sm' ? 'h-8 px-3 text-[13px] rounded-md' : 'h-10 px-4 text-sm rounded-md',
|
size === 'sm' ? 'h-8 px-3 text-[13px] rounded-md' : 'h-10 px-4 text-sm rounded-md',
|
||||||
@@ -24,8 +30,13 @@ withDefaults(
|
|||||||
variant === 'ghost' && 'text-paper-500 hover:text-paper-100 hover:bg-ink-800',
|
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',
|
variant === 'danger' && 'bg-ember-500/90 text-white hover:bg-ember-400',
|
||||||
]"
|
]"
|
||||||
|
@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" />
|
<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"
|
||||||
|
/>
|
||||||
<slot />
|
<slot />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{
|
import { computed, ref, useId } from 'vue'
|
||||||
|
|
||||||
|
type InputMode = 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
label?: string
|
label?: string
|
||||||
type?: string
|
type?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
@@ -8,28 +12,68 @@ withDefaults(defineProps<{
|
|||||||
hint?: string
|
hint?: string
|
||||||
mono?: boolean
|
mono?: boolean
|
||||||
autocomplete?: string
|
autocomplete?: string
|
||||||
}>(), { type: 'text', placeholder: '', modelValue: '' })
|
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 }>()
|
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 })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label class="block">
|
<div>
|
||||||
<span v-if="label" class="mb-1.5 block text-[13px] font-medium text-paper-300">{{ label }}</span>
|
<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>
|
||||||
<input
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
:id="inputId"
|
||||||
:type="type"
|
:type="type"
|
||||||
|
:name="name"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:autocomplete="autocomplete"
|
: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)"
|
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
class="h-10 w-full rounded-md border bg-ink-900 px-3 text-sm text-paper-100 placeholder:text-paper-600
|
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
|
||||||
transition-colors focus:border-signal-400 focus:outline-none"
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-400/60"
|
||||||
:class="[
|
:class="[
|
||||||
mono ? 'font-mono' : '',
|
mono ? 'font-mono' : '',
|
||||||
error ? 'border-ember-500' : 'border-ink-600 hover:border-ink-700',
|
error ? 'border-ember-500' : 'border-ink-600 hover:border-ink-700',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<span v-if="error" class="mt-1 block text-xs text-ember-400">{{ error }}</span>
|
<p v-if="error" :id="descId" class="mt-1 flex items-start gap-1 text-xs text-ember-400" role="alert">
|
||||||
<span v-else-if="hint" class="mt-1 block text-xs text-paper-600">{{ hint }}</span>
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="mt-px shrink-0" aria-hidden="true">
|
||||||
</label>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,23 +1,88 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ title: string; open: boolean; width?: string }>()
|
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||||
defineEmits<{ (e: 'close'): void }>()
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ title: string; open: boolean; width?: string }>(),
|
||||||
|
{ width: 'max-w-md' },
|
||||||
|
)
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
|
const lastFocus = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-[12vh]">
|
<div
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-[2px]" @click="$emit('close')" />
|
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"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-black/60 backdrop-blur-[2px]" aria-hidden="true" @click="emit('close')" />
|
||||||
<div
|
<div
|
||||||
|
ref="dialogRef"
|
||||||
class="relative w-full rounded-lg border border-ink-700 bg-ink-900 shadow-2xl"
|
class="relative w-full rounded-lg border border-ink-700 bg-ink-900 shadow-2xl"
|
||||||
:class="width ?? 'max-w-md'"
|
:class="width"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
:aria-labelledby="`modal-title-${title}`"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between border-b border-ink-700 px-5 py-3.5">
|
<div class="flex items-center justify-between border-b border-ink-700 px-5 py-3.5">
|
||||||
<h3 class="text-sm font-semibold text-paper-100">{{ title }}</h3>
|
<h3 :id="`modal-title-${title}`" class="text-sm font-semibold text-paper-100">{{ title }}</h3>
|
||||||
<button class="text-paper-500 transition-colors hover:text-paper-100 cursor-pointer" @click="$emit('close')" aria-label="关闭">
|
<button
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5 py-4"><slot /></div>
|
<div class="px-5 py-4"><slot /></div>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<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,27 @@
|
|||||||
|
// Toast 状态:轻量全局消息队列(操作反馈,aria-live 播报)
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
kind: 'success' | 'error' | 'info'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
export const useToastStore = defineStore('toast', {
|
||||||
|
state: () => ({ items: [] as Toast[] }),
|
||||||
|
actions: {
|
||||||
|
push(kind: Toast['kind'], message: string) {
|
||||||
|
const id = ++seq
|
||||||
|
this.items.push({ id, kind, message })
|
||||||
|
setTimeout(() => this.dismiss(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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -18,54 +18,67 @@ const protocols = [
|
|||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="sticky top-0 z-40 border-b border-ink-800 bg-ink-950/90 backdrop-blur">
|
<header class="sticky top-0 z-40 border-b border-ink-800 bg-ink-950/90 backdrop-blur">
|
||||||
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
|
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
|
||||||
<div class="flex items-center gap-2.5">
|
<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">
|
<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>
|
<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>
|
||||||
<span class="text-[15px] font-semibold tracking-tight">openteam</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">relay</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>
|
||||||
</div>
|
</router-link>
|
||||||
<nav class="flex items-center gap-2">
|
<nav class="flex items-center gap-2" aria-label="站内导航">
|
||||||
<a v-if="!auth.isAuthed" href="/login" class="rounded-md px-3 py-2 text-sm text-paper-500 transition-colors hover:text-paper-100">登录</a>
|
<router-link
|
||||||
<a :href="primaryAction" class="rounded-md bg-signal-400 px-4 py-2 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300">开始使用</a>
|
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>
|
||||||
|
<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>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main id="landing-main">
|
||||||
<!-- Hero:左对齐,信号路径示意 -->
|
<!-- 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]">
|
<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>
|
<div>
|
||||||
<p class="font-mono text-xs uppercase tracking-[0.2em] text-signal-400">self-hosted llm relay</p>
|
<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 md:text-5xl">
|
<h1 class="mt-4 text-4xl leading-[1.05] font-bold tracking-tight text-balance md:text-5xl">
|
||||||
一个 Key,<br />接入全部模型
|
一个 Key,<br />接入全部模型
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-5 max-w-[52ch] text-base leading-relaxed text-paper-500">
|
<p class="mt-5 max-w-[52ch] text-base leading-relaxed text-paper-500">
|
||||||
自托管 LLM API 中转网关。统一 OpenAI 与 Anthropic 协议入口,背后对接任意上游渠道,用量计费一目了然。
|
自托管 LLM API 中转网关。统一 OpenAI 与 Anthropic 协议入口,背后对接任意上游渠道,用量计费一目了然。
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-8 flex items-center gap-3">
|
<div class="mt-8 flex items-center gap-3">
|
||||||
<a href="/register" class="inline-flex h-10 items-center rounded-md bg-signal-400 px-5 text-sm font-medium text-ink-950 transition-all hover:bg-signal-300 active:scale-[0.98]">立即开始</a>
|
<router-link
|
||||||
<a href="#protocols" class="inline-flex h-10 items-center rounded-md border border-ink-600 px-5 text-sm text-paper-300 transition-colors hover:border-signal-400 hover:text-signal-300">查看端点</a>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 信号路径:client → gateway → upstream -->
|
<!-- 信号路径:client → gateway → upstream -->
|
||||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-5 font-mono text-[13px]">
|
<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">
|
<div class="flex items-center gap-3 pb-4">
|
||||||
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-mint-400" />
|
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-mint-400" aria-hidden="true" />
|
||||||
<span class="text-xs text-paper-500">request path · live</span>
|
<span class="text-xs text-paper-500" translate="no">request path · live</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">client <span class="text-signal-400">──▶</span> <span class="text-paper-500">/v1/chat/completions</span></div>
|
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">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">──▶</span> <span class="text-paper-500">auth · quota · route</span></div>
|
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">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">◀──</span> <span class="text-paper-500">openai / anthropic</span></div>
|
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">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">──▶</span> <span class="num text-paper-500">usage · cost · ledger</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 协议端点 -->
|
<!-- 协议端点 -->
|
||||||
<section id="protocols" class="border-t border-ink-800 bg-ink-900/50">
|
<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">
|
<div class="mx-auto max-w-6xl px-6 py-16">
|
||||||
<h2 class="text-xl font-semibold tracking-tight">三套协议,一个入口</h2>
|
<h2 class="text-xl font-semibold tracking-tight">三套协议,一个入口</h2>
|
||||||
<p class="mt-2 max-w-[60ch] text-sm leading-relaxed text-paper-500">
|
<p class="mt-2 max-w-[60ch] text-sm leading-relaxed text-paper-500">
|
||||||
@@ -73,7 +86,7 @@ const protocols = [
|
|||||||
</p>
|
</p>
|
||||||
<div class="mt-8 grid grid-cols-1 gap-3 md:grid-cols-2">
|
<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">
|
<div v-for="p in protocols" :key="p.name" class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||||
<code class="font-mono text-[13px] text-signal-300">{{ p.name }}</code>
|
<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>
|
<p class="mt-1.5 text-[13px] text-paper-500">{{ p.desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +97,7 @@ const protocols = [
|
|||||||
<footer class="border-t border-ink-800">
|
<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">
|
<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>openteam · 自托管 LLM 中转网关</span>
|
||||||
<span class="font-mono">v0.1 · M1</span>
|
<span class="font-mono" translate="no">v0.1 · M1</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ const loading = ref(false)
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
if (!username.value.trim() || !password.value) {
|
||||||
|
error.value = '请输入用户名和密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value.trim(), password.value)
|
||||||
const redirect = (route.query.redirect as string) || '/console/dashboard'
|
const redirect = (route.query.redirect as string) || '/console/dashboard'
|
||||||
router.push(redirect)
|
router.push(redirect)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -33,28 +37,49 @@ async function submit() {
|
|||||||
<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-ink-950 px-4">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<div class="mb-8 flex items-center gap-2.5">
|
<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">
|
<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>
|
<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>
|
||||||
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
|
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold tracking-tight">登录控制台</h1>
|
<h1 class="text-xl font-semibold tracking-tight">登录控制台</h1>
|
||||||
<p class="mt-1 text-sm text-paper-500">管理密钥、查看用量与余额</p>
|
<p class="mt-1 text-sm text-paper-500">管理密钥、查看用量与余额</p>
|
||||||
|
|
||||||
<form class="mt-8 space-y-4" @submit.prevent="submit">
|
<form class="mt-8 space-y-4" @submit.prevent="submit" novalidate>
|
||||||
<Input v-model="username" label="用户名或邮箱" placeholder="alice" autocomplete="username" />
|
<Input
|
||||||
<Input v-model="password" label="密码" type="password" placeholder="••••••••" autocomplete="current-password" />
|
v-model="username"
|
||||||
<p v-if="error" class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
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>
|
<Button type="submit" class="w-full" :loading="loading">登录</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-6 text-center text-[13px] text-paper-500">
|
<p class="mt-6 text-center text-[13px] text-paper-500">
|
||||||
还没有账号?
|
还没有账号?
|
||||||
<router-link to="/register" class="text-signal-300 hover:text-signal-200">注册</router-link>
|
<router-link to="/register" class="text-signal-300 transition-colors hover:text-signal-200">注册</router-link>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-4 text-center">
|
<p class="mt-4 text-center">
|
||||||
<router-link to="/" class="text-xs text-paper-600 hover:text-paper-500">← 返回首页</router-link>
|
<router-link to="/" class="text-xs text-paper-600 transition-colors hover:text-paper-500">← 返回首页</router-link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,26 +12,33 @@ const username = ref('')
|
|||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirm = ref('')
|
const confirm = ref('')
|
||||||
const error = ref('')
|
const formError = ref('')
|
||||||
|
const errors = ref<Record<string, string>>({})
|
||||||
const loading = ref(false)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
error.value = ''
|
formError.value = ''
|
||||||
if (password.value !== confirm.value) {
|
if (!validate()) return
|
||||||
error.value = '两次输入的密码不一致'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value.length < 8) {
|
|
||||||
error.value = '密码至少 8 位'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await auth.register(username.value, email.value, password.value)
|
await auth.register(username.value.trim(), email.value.trim(), password.value)
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value.trim(), password.value)
|
||||||
router.push('/console/dashboard')
|
router.push('/console/dashboard')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.response?.data?.error?.message || '注册失败,请稍后重试'
|
formError.value = e.response?.data?.error?.message || '注册失败,请稍后重试'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -42,27 +49,68 @@ async function submit() {
|
|||||||
<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-ink-950 px-4">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<div class="mb-8 flex items-center gap-2.5">
|
<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">
|
<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>
|
<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>
|
||||||
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
|
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold tracking-tight">创建账号</h1>
|
<h1 class="text-xl font-semibold tracking-tight">创建账号</h1>
|
||||||
<p class="mt-1 text-sm text-paper-500">注册即赠体验额度,一个 Key 接入全部模型</p>
|
<p class="mt-1 text-sm text-paper-500">注册即赠体验额度,一个 Key 接入全部模型</p>
|
||||||
|
|
||||||
<form class="mt-8 space-y-4" @submit.prevent="submit">
|
<form class="mt-8 space-y-4" @submit.prevent="submit" novalidate>
|
||||||
<Input v-model="username" label="用户名" placeholder="alice" autocomplete="username" />
|
<Input
|
||||||
<Input v-model="email" label="邮箱" type="email" placeholder="alice@example.com" autocomplete="email" />
|
v-model="username"
|
||||||
<Input v-model="password" label="密码" type="password" placeholder="至少 8 位" autocomplete="new-password" hint="使用 argon2id 加密存储" />
|
label="用户名"
|
||||||
<Input v-model="confirm" label="确认密码" type="password" placeholder="再次输入" autocomplete="new-password" />
|
name="username"
|
||||||
<p v-if="error" class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
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"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="password"
|
||||||
|
label="密码"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="至少 8 位…"
|
||||||
|
autocomplete="new-password"
|
||||||
|
hint="使用 argon2id 加密存储"
|
||||||
|
:error="errors.password"
|
||||||
|
/>
|
||||||
|
<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>
|
<Button type="submit" class="w-full" :loading="loading">注册</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-6 text-center text-[13px] text-paper-500">
|
<p class="mt-6 text-center text-[13px] text-paper-500">
|
||||||
已有账号?
|
已有账号?
|
||||||
<router-link to="/login" class="text-signal-300 hover:text-signal-200">登录</router-link>
|
<router-link to="/login" class="text-signal-300 transition-colors hover:text-signal-200">登录</router-link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import Toast from '../../components/ui/Toast.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -12,31 +13,43 @@ const nav = [
|
|||||||
{ to: '/console/usage', label: '用量明细', icon: 'M4 20V10M10 20V4M16 20v-7M22 20H2' },
|
{ to: '/console/usage', label: '用量明细', icon: 'M4 20V10M10 20V4M16 20v-7M22 20H2' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const balanceFmt = computed(() =>
|
const balanceFmt = computed(() => {
|
||||||
auth.user ? auth.user.balance.toFixed(4) : '—',
|
if (!auth.user) return '—'
|
||||||
)
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(auth.user.balance)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await auth.logout()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-[100dvh] bg-ink-950">
|
<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 class="fixed inset-y-0 left-0 z-30 flex w-56 flex-col border-r border-ink-800 bg-ink-900/60">
|
<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">
|
<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">
|
<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>
|
<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>
|
||||||
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
|
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 space-y-0.5 px-3 py-4">
|
<nav class="flex-1 space-y-0.5 px-3 py-4" aria-label="控制台">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in nav"
|
v-for="item in nav"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="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"
|
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"
|
active-class="bg-ink-800 text-signal-300! font-medium"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
|
<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 }}
|
{{ item.label }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -47,13 +60,13 @@ const balanceFmt = computed(() =>
|
|||||||
<p class="truncate text-[13px] font-medium text-paper-100">{{ auth.user?.username }}</p>
|
<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>
|
<p class="text-xs text-paper-600">{{ auth.isAdmin ? 'admin' : 'user' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="num text-[13px] font-medium text-mint-400">${{ balanceFmt }}</span>
|
<span class="num text-[13px] font-medium text-mint-400" translate="no">{{ balanceFmt }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="mt-2 flex w-full items-center justify-center gap-2 rounded-md px-3 py-2 text-[13px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-ember-300 cursor-pointer"
|
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="auth.logout().then(() => router.push('/'))"
|
@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"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></svg>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,9 +74,12 @@ const balanceFmt = computed(() =>
|
|||||||
|
|
||||||
<!-- 主区域 -->
|
<!-- 主区域 -->
|
||||||
<div class="ml-56 flex-1">
|
<div class="ml-56 flex-1">
|
||||||
<div class="mx-auto max-w-6xl px-8 py-8">
|
<main id="main-content" class="mx-auto max-w-6xl scroll-mt-4 px-8 py-8" tabindex="-1">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局通知 -->
|
||||||
|
<Toast />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,8 +18,17 @@ const balance = ref<BalanceInfo | null>(null)
|
|||||||
const stats = ref<UsagePoint[]>([])
|
const stats = ref<UsagePoint[]>([])
|
||||||
const recentLogs = ref<LogItem[]>([])
|
const recentLogs = ref<LogItem[]>([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
const todayCost = computed(() => balance.value?.today.cost ?? 0)
|
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(() => ({
|
const chartOption = computed(() => ({
|
||||||
grid: { left: 8, right: 8, top: 24, bottom: 0, containLabel: true },
|
grid: { left: 8, right: 8, top: 24, bottom: 0, containLabel: true },
|
||||||
@@ -64,10 +73,12 @@ onMounted(async () => {
|
|||||||
recentLogs.value = logs.items ?? []
|
recentLogs.value = logs.items ?? []
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message || '加载失败'
|
error.value = e.message || '加载失败'
|
||||||
|
} finally {
|
||||||
|
loaded.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
|
const fmtCost = (n: number) => usd.format(n)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -77,64 +88,80 @@ const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
|
|||||||
<h1 class="text-xl font-semibold tracking-tight">仪表盘</h1>
|
<h1 class="text-xl font-semibold tracking-tight">仪表盘</h1>
|
||||||
<p class="mt-1 text-[13px] text-paper-500">今日与近 30 日用量总览</p>
|
<p class="mt-1 text-[13px] text-paper-500">今日与近 30 日用量总览</p>
|
||||||
</div>
|
</div>
|
||||||
<router-link to="/console/keys" class="rounded-md bg-signal-400 px-4 py-2 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300">新建密钥</router-link>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
<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 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<section aria-label="用量指标" class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
<template v-if="!loaded">
|
||||||
<p class="text-xs text-paper-500">余额</p>
|
<div v-for="i in 4" :key="i" class="rounded-lg border border-ink-700 bg-ink-900 p-4" aria-hidden="true">
|
||||||
<p class="num mt-1.5 text-2xl font-semibold text-mint-400">${{ balance?.balance.toFixed(4) ?? '—' }}</p>
|
<div class="h-3 w-14 animate-pulse rounded bg-ink-700" />
|
||||||
<p class="num mt-1 text-[11px] text-paper-600">30 日消耗 ${{ fmtCost(balance?.spent_last_30d ?? 0) }}</p>
|
<div class="mt-3 h-7 w-24 animate-pulse rounded bg-ink-700" />
|
||||||
</div>
|
<div class="mt-2 h-3 w-20 animate-pulse rounded bg-ink-700" />
|
||||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
</div>
|
||||||
<p class="text-xs text-paper-500">今日请求</p>
|
</template>
|
||||||
<p class="num mt-1.5 text-2xl font-semibold">{{ balance?.today.requests ?? '—' }}</p>
|
<template v-else>
|
||||||
<p class="num mt-1 text-[11px] text-paper-600">{{ balance?.today.tokens ?? 0 }} tokens</p>
|
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||||
</div>
|
<p class="text-xs text-paper-500">余额</p>
|
||||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
<p class="num mt-1.5 text-2xl font-semibold text-mint-400" translate="no">{{ balanceText }}</p>
|
||||||
<p class="text-xs text-paper-500">今日成本</p>
|
<p class="num mt-1 text-[11px] text-paper-600">30 日消耗 <span translate="no">{{ spent30d }}</span></p>
|
||||||
<p class="num mt-1.5 text-2xl font-semibold text-signal-300">${{ todayCost.toFixed(6) }}</p>
|
</div>
|
||||||
<p class="num mt-1 text-[11px] text-paper-600">按量计费 · USD</p>
|
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||||
</div>
|
<p class="text-xs text-paper-500">今日请求</p>
|
||||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
<p class="num mt-1.5 text-2xl font-semibold">{{ todayRequests }}</p>
|
||||||
<p class="text-xs text-paper-500">可用模型</p>
|
<p class="num mt-1 text-[11px] text-paper-600">{{ todayTokens }} tokens</p>
|
||||||
<p class="num mt-1.5 text-2xl font-semibold">{{ balance?.models_available ?? '—' }}</p>
|
</div>
|
||||||
<p class="mt-1 text-[11px] text-paper-600">GET /v1/models 查看</p>
|
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||||
</div>
|
<p class="text-xs text-paper-500">今日成本</p>
|
||||||
</div>
|
<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="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="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<h2 class="text-sm font-medium text-paper-300">近 30 日请求</h2>
|
<h2 class="text-sm font-medium text-paper-300">近 30 日请求</h2>
|
||||||
<span class="font-mono text-[11px] text-paper-600">usage/stats?group=day</span>
|
<span class="font-mono text-[11px] text-paper-600" translate="no">usage/stats?group=day</span>
|
||||||
</div>
|
</div>
|
||||||
<VChart v-if="stats.length" class="h-56" :option="chartOption" autoresize />
|
<VChart v-if="stats.length" class="h-56" :option="chartOption" autoresize />
|
||||||
<div v-else class="flex h-56 items-center justify-center text-[13px] text-paper-600">暂无数据,发起第一次请求后这里会出现图表</div>
|
<div 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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<h2 class="text-sm font-medium text-paper-300">最近请求</h2>
|
<h2 class="text-sm font-medium text-paper-300">最近请求</h2>
|
||||||
<router-link to="/console/usage" class="text-xs text-signal-300 hover:text-signal-200">全部 →</router-link>
|
<router-link to="/console/usage" class="text-xs text-signal-300 transition-colors hover:text-signal-200">全部 →</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="recentLogs.length" class="divide-y divide-ink-800">
|
<div v-if="recentLogs.length" class="divide-y divide-ink-800">
|
||||||
<div v-for="l in recentLogs" :key="l.id" class="flex items-center justify-between gap-3 py-2.5">
|
<div v-for="l in recentLogs" :key="l.id" class="flex items-center justify-between gap-3 py-2.5">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate font-mono text-[12.5px] text-paper-100">{{ l.model }}</p>
|
<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">{{ l.protocol }} · {{ l.input_tokens }}/{{ l.output_tokens }} tok · {{ l.latency_ms }}ms</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>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<span class="num text-[12.5px] text-paper-300">${{ fmtCost(l.cost) }}</span>
|
<span class="num text-[12.5px] text-paper-300" translate="no">{{ fmtCost(l.cost) }}</span>
|
||||||
<Badge :tone="l.status === 'success' ? 'success' : 'error'" />
|
<Badge :tone="l.status === 'success' ? 'success' : 'error'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex h-48 items-center justify-center text-[13px] text-paper-600">还没有请求记录</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import client, { unwrap } from '../../api/client'
|
import client, { unwrap } from '../../api/client'
|
||||||
import Badge from '../../components/ui/Badge.vue'
|
import Badge from '../../components/ui/Badge.vue'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
import Modal from '../../components/ui/Modal.vue'
|
import Modal from '../../components/ui/Modal.vue'
|
||||||
import Input from '../../components/ui/Input.vue'
|
import Input from '../../components/ui/Input.vue'
|
||||||
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
interface APIKey {
|
interface APIKey {
|
||||||
id: number
|
id: number
|
||||||
@@ -17,6 +18,8 @@ interface APIKey {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
const keys = ref<APIKey[]>([])
|
const keys = ref<APIKey[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -27,6 +30,7 @@ const newName = ref('')
|
|||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const createdKey = ref('')
|
const createdKey = ref('')
|
||||||
const createError = ref('')
|
const createError = ref('')
|
||||||
|
const nameInput = ref<{ focus: () => void } | null>(null)
|
||||||
|
|
||||||
// 吊销
|
// 吊销
|
||||||
const revokeTarget = ref<APIKey | null>(null)
|
const revokeTarget = ref<APIKey | null>(null)
|
||||||
@@ -48,6 +52,8 @@ async function create() {
|
|||||||
createError.value = ''
|
createError.value = ''
|
||||||
if (!newName.value.trim()) {
|
if (!newName.value.trim()) {
|
||||||
createError.value = '请填写密钥名称'
|
createError.value = '请填写密钥名称'
|
||||||
|
await nextTick()
|
||||||
|
nameInput.value?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
creating.value = true
|
creating.value = true
|
||||||
@@ -68,10 +74,11 @@ async function revoke() {
|
|||||||
revoking.value = true
|
revoking.value = true
|
||||||
try {
|
try {
|
||||||
await unwrap(client.delete(`/keys/${revokeTarget.value.id}`))
|
await unwrap(client.delete(`/keys/${revokeTarget.value.id}`))
|
||||||
|
toast.success(`密钥 ${revokeTarget.value.key_prefix}… 已吊销`)
|
||||||
revokeTarget.value = null
|
revokeTarget.value = null
|
||||||
await load()
|
await load()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message || '吊销失败'
|
toast.error(e.message || '吊销失败')
|
||||||
} finally {
|
} finally {
|
||||||
revoking.value = false
|
revoking.value = false
|
||||||
}
|
}
|
||||||
@@ -83,16 +90,20 @@ async function copyKey(text: string) {
|
|||||||
} catch {
|
} catch {
|
||||||
const ta = document.createElement('textarea')
|
const ta = document.createElement('textarea')
|
||||||
ta.value = text
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
document.body.appendChild(ta)
|
document.body.appendChild(ta)
|
||||||
ta.select()
|
ta.select()
|
||||||
document.execCommand('copy')
|
document.execCommand('copy')
|
||||||
ta.remove()
|
ta.remove()
|
||||||
}
|
}
|
||||||
|
toast.success('密钥已复制')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleString('zh-CN', { hour12: false }) : '从未使用')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -105,34 +116,35 @@ const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleString('zh-CN', {
|
|||||||
<Button @click="showCreate = true">新建密钥</Button>
|
<Button @click="showCreate = true">新建密钥</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
|
<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">
|
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
|
||||||
<table class="w-full text-left text-[13px]">
|
<table class="w-full text-left text-[13px]">
|
||||||
|
<caption class="sr-only">API 密钥列表</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
||||||
<th class="px-4 py-3 font-medium">名称</th>
|
<th scope="col" class="px-4 py-3 font-medium">名称</th>
|
||||||
<th class="px-4 py-3 font-medium">密钥前缀</th>
|
<th scope="col" class="px-4 py-3 font-medium">密钥前缀</th>
|
||||||
<th class="px-4 py-3 font-medium">每日限额</th>
|
<th scope="col" class="px-4 py-3 font-medium">每日限额</th>
|
||||||
<th class="px-4 py-3 font-medium">最近使用</th>
|
<th scope="col" class="px-4 py-3 font-medium">最近使用</th>
|
||||||
<th class="px-4 py-3 font-medium">状态</th>
|
<th scope="col" class="px-4 py-3 font-medium">状态</th>
|
||||||
<th class="px-4 py-3 text-right font-medium">操作</th>
|
<th scope="col" class="px-4 py-3 text-right font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
|
<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">
|
<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 font-medium text-paper-100">{{ k.name }}</td>
|
||||||
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-signal-300">{{ k.key_prefix }}…</code></td>
|
<td class="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">
|
<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_tokens_per_day ? `${(k.quota_tokens_per_day / 1000).toFixed(0)}k tok` : '—' }}
|
||||||
/ {{ k.quota_requests_per_day ? `${k.quota_requests_per_day} req` : '—' }}
|
/ {{ k.quota_requests_per_day ? `${k.quota_requests_per_day} req` : '—' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="num px-4 py-3 text-paper-500">{{ fmtDate(k.last_used_at) }}</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"><Badge :tone="k.status" /></td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
v-if="k.status === 'active'"
|
v-if="k.status === 'active'"
|
||||||
class="text-xs text-ember-400 transition-colors hover:text-ember-300 cursor-pointer"
|
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"
|
@click="revokeTarget = k"
|
||||||
>吊销</button>
|
>吊销</button>
|
||||||
<span v-else class="text-xs text-paper-600">已吊销</span>
|
<span v-else class="text-xs text-paper-600">已吊销</span>
|
||||||
@@ -140,7 +152,7 @@ const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleString('zh-CN', {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!keys.length">
|
<tr v-if="!keys.length">
|
||||||
<td colspan="6" class="px-4 py-12 text-center text-[13px] text-paper-600">
|
<td colspan="6" class="px-4 py-12 text-center text-[13px] text-paper-600">
|
||||||
还没有密钥 — 点击右上角「新建密钥」创建第一个
|
{{ loading ? '加载中…' : '还没有密钥 — 点击右上角「新建密钥」创建第一个' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -150,21 +162,28 @@ const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleString('zh-CN', {
|
|||||||
<!-- 创建模态 -->
|
<!-- 创建模态 -->
|
||||||
<Modal :open="showCreate" title="新建 API 密钥" @close="showCreate = false">
|
<Modal :open="showCreate" title="新建 API 密钥" @close="showCreate = false">
|
||||||
<template v-if="!createdKey">
|
<template v-if="!createdKey">
|
||||||
<Input v-model="newName" label="密钥名称" placeholder="例如:本地开发" hint="用于在用量明细中区分来源" />
|
<Input
|
||||||
<p v-if="createError" class="mt-3 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ createError }}</p>
|
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">
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
<Button variant="ghost" @click="showCreate = false">取消</Button>
|
<Button variant="ghost" @click="showCreate = false">取消</Button>
|
||||||
<Button :loading="creating" @click="create">创建</Button>
|
<Button :loading="creating" @click="create">创建密钥</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-[13px] leading-relaxed text-paper-500">密钥已生成。出于安全考虑,<span class="text-paper-300">明文只会展示这一次</span>,请立即复制保存。</p>
|
<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">
|
<div class="mt-3 flex items-center gap-2 rounded-md border border-mint-500/40 bg-mint-400/10 px-3 py-2.5">
|
||||||
<code class="flex-1 break-all font-mono text-[12.5px] text-mint-300">{{ createdKey }}</code>
|
<code class="min-w-0 flex-1 break-all font-mono text-[12.5px] text-mint-300" translate="no">{{ createdKey }}</code>
|
||||||
<button
|
<Button variant="outline" size="sm" @click="copyKey(createdKey)">复制</Button>
|
||||||
class="shrink-0 text-xs text-mint-300 transition-colors hover:text-mint-400 cursor-pointer"
|
|
||||||
@click="copyKey(createdKey)"
|
|
||||||
>复制</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button @click="showCreate = false; createdKey = ''">完成</Button>
|
<Button @click="showCreate = false; createdKey = ''">完成</Button>
|
||||||
@@ -175,7 +194,7 @@ const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleString('zh-CN', {
|
|||||||
<!-- 吊销确认 -->
|
<!-- 吊销确认 -->
|
||||||
<Modal :open="!!revokeTarget" title="吊销密钥" @close="revokeTarget = null">
|
<Modal :open="!!revokeTarget" title="吊销密钥" @close="revokeTarget = null">
|
||||||
<p class="text-[13px] leading-relaxed text-paper-500">
|
<p class="text-[13px] leading-relaxed text-paper-500">
|
||||||
吊销后 <code class="font-mono text-paper-300">{{ revokeTarget?.key_prefix }}…</code> 将立即失效,使用它的请求会返回 401。此操作不可撤销。
|
吊销后 <code class="font-mono text-paper-300" translate="no">{{ revokeTarget?.key_prefix }}…</code> 将立即失效,使用它的请求会返回 401。此操作不可撤销。
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-5 flex justify-end gap-2">
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
<Button variant="ghost" @click="revokeTarget = null">取消</Button>
|
<Button variant="ghost" @click="revokeTarget = null">取消</Button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import client, { unwrap } from '../../api/client'
|
import client, { unwrap } from '../../api/client'
|
||||||
import Badge from '../../components/ui/Badge.vue'
|
import Badge from '../../components/ui/Badge.vue'
|
||||||
|
|
||||||
@@ -17,15 +18,30 @@ interface LogItem {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const logs = ref<LogItem[]>([])
|
const logs = ref<LogItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(Number(route.query.page) || 1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const modelFilter = ref('')
|
const modelFilter = ref((route.query.model as string) || '')
|
||||||
const models = ref<string[]>([])
|
const models = ref<string[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
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) } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -33,14 +49,10 @@ async function load() {
|
|||||||
const data = await unwrap<{ items: LogItem[]; total: number }>(
|
const data = await unwrap<{ items: LogItem[]; total: number }>(
|
||||||
client.get('/usage/logs', { params: { page: page.value, page_size: pageSize, model: modelFilter.value || undefined } }),
|
client.get('/usage/logs', { params: { page: page.value, page_size: pageSize, model: modelFilter.value || undefined } }),
|
||||||
)
|
)
|
||||||
logs.value = data.items
|
logs.value = data.items ?? []
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
if (!modelFilter.value) {
|
|
||||||
const m = await unwrap<{ items: string[] }>(client.get('/usage/logs', { params: { page_size: 1 } })).catch(() => ({ items: [] }))
|
|
||||||
void m
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 加载失败静默,空态兜底
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -58,8 +70,8 @@ onMounted(() => {
|
|||||||
loadModels()
|
loadModels()
|
||||||
})
|
})
|
||||||
|
|
||||||
const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
|
const fmtCost = (n: number) => usd.format(n)
|
||||||
const fmtDate = (s: string) => new Date(s).toLocaleString('zh-CN', { hour12: false })
|
const fmtDateStr = (s: string) => fmtDate.format(new Date(s))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,39 +81,43 @@ const fmtDate = (s: string) => new Date(s).toLocaleString('zh-CN', { hour12: fal
|
|||||||
<h1 class="text-xl font-semibold tracking-tight">用量明细</h1>
|
<h1 class="text-xl font-semibold tracking-tight">用量明细</h1>
|
||||||
<p class="mt-1 text-[13px] text-paper-500">请求级记录 · 按当时价格入账</p>
|
<p class="mt-1 text-[13px] text-paper-500">请求级记录 · 按当时价格入账</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<label class="flex items-center gap-2">
|
||||||
v-model="modelFilter"
|
<span class="text-xs text-paper-600">模型</span>
|
||||||
class="h-9 rounded-md border border-ink-600 bg-ink-900 px-3 text-[13px] text-paper-300 focus:border-signal-400 focus:outline-none"
|
<select
|
||||||
@change="page = 1; load()"
|
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"
|
||||||
<option value="">全部模型</option>
|
@change="page = 1; load()"
|
||||||
<option v-for="m in models" :key="m" :value="m">{{ m }}</option>
|
>
|
||||||
</select>
|
<option value="">全部模型</option>
|
||||||
|
<option v-for="m in models" :key="m" :value="m" translate="no">{{ m }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
|
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
|
||||||
<table class="w-full text-left text-[13px]">
|
<table class="w-full text-left text-[13px]">
|
||||||
|
<caption class="sr-only">用量明细记录</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
|
||||||
<th class="px-4 py-3 font-medium">时间</th>
|
<th scope="col" class="px-4 py-3 font-medium">时间</th>
|
||||||
<th class="px-4 py-3 font-medium">模型</th>
|
<th scope="col" class="px-4 py-3 font-medium">模型</th>
|
||||||
<th class="px-4 py-3 font-medium">协议</th>
|
<th scope="col" class="px-4 py-3 font-medium">协议</th>
|
||||||
<th 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 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 class="px-4 py-3 text-right font-medium">成本</th>
|
<th scope="col" class="px-4 py-3 text-right font-medium">成本</th>
|
||||||
<th class="px-4 py-3 text-right font-medium">耗时</th>
|
<th scope="col" class="px-4 py-3 text-right font-medium">耗时</th>
|
||||||
<th class="px-4 py-3 font-medium">状态</th>
|
<th scope="col" class="px-4 py-3 font-medium">状态</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
|
<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">
|
<tr v-for="l in logs" :key="l.id" class="transition-colors hover:bg-ink-850">
|
||||||
<td class="num px-4 py-3 text-paper-500">{{ fmtDate(l.created_at) }}</td>
|
<td class="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">{{ l.model }}</code></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">{{ l.protocol }}</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">{{ l.input_tokens }}</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">{{ l.output_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">${{ fmtCost(l.cost) }}</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">{{ l.latency_ms }}ms</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>
|
<td class="px-4 py-3"><Badge :tone="l.status === 'success' ? 'success' : 'error'" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!logs.length">
|
<tr v-if="!logs.length">
|
||||||
@@ -113,19 +129,21 @@ const fmtDate = (s: string) => new Date(s).toLocaleString('zh-CN', { hour12: fal
|
|||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-4 flex items-center justify-between">
|
||||||
<p class="num text-xs text-paper-600">共 {{ total }} 条</p>
|
<p class="num text-xs text-paper-600">共 {{ total }} 条</p>
|
||||||
<div class="flex items-center gap-1.5">
|
<nav aria-label="分页" class="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
class="rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default"
|
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"
|
:disabled="page <= 1"
|
||||||
|
:aria-label="`上一页,当前第 ${page} 页`"
|
||||||
@click="page--; load()"
|
@click="page--; load()"
|
||||||
>上一页</button>
|
>上一页</button>
|
||||||
<span class="num px-2 text-xs text-paper-500">{{ page }} / {{ pages }}</span>
|
<span class="num px-2 text-xs text-paper-500" aria-current="page">第 {{ page }} / {{ pages }} 页</span>
|
||||||
<button
|
<button
|
||||||
class="rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default"
|
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"
|
:disabled="page >= pages"
|
||||||
|
:aria-label="`下一页,当前第 ${page} 页`"
|
||||||
@click="page++; load()"
|
@click="page++; load()"
|
||||||
>下一页</button>
|
>下一页</button>
|
||||||
</div>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user