chore: initial public snapshot for github upload

This commit is contained in:
Your Name
2026-03-26 20:06:14 +08:00
commit 0e5ecd930e
3497 changed files with 1586236 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
# 忽略编译后的文件
vite.config.js
vite.config.d.ts
# 忽略依赖
node_modules/
# 忽略构建输出
dist/
../backend/internal/web/dist/
# 忽略缓存
.cache/
.vite/

View File

@@ -0,0 +1,36 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
ecmaVersion: "latest",
sourceType: "module",
extraFileExtensions: [".vue"],
},
plugins: ["vue", "@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:vue/vue3-essential",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-constant-condition": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"vue/multi-word-component-names": "off",
"vue/no-use-v-if-with-v-for": "off",
},
};

View File

@@ -0,0 +1,4 @@
legacy-peer-deps=true
# 允许运行所有包的构建脚本
# esbuild 和 vue-demi 是已知安全的包,需要 postinstall 脚本才能正常工作
ignore-scripts=false

View File

@@ -0,0 +1,118 @@
{
"actions": [
{
"action": "review",
"module": "xlsx",
"resolves": [
{
"id": 1108110,
"path": ".>xlsx",
"dev": false,
"bundled": false,
"optional": false
},
{
"id": 1108111,
"path": ".>xlsx",
"dev": false,
"bundled": false,
"optional": false
}
]
}
],
"advisories": {
"1108110": {
"findings": [
{
"version": "0.18.5",
"paths": [
".>xlsx"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://nvd.nist.gov/vuln/detail/CVE-2023-30533\n- https://cdn.sheetjs.com/advisories/CVE-2023-30533\n- https://git.sheetjs.com/sheetjs/sheetjs/src/branch/master/CHANGELOG.md\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2667\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2986\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-4r6h-8v6p-xvw6",
"created": "2023-04-24T09:30:19.000Z",
"id": 1108110,
"npm_advisory_id": null,
"overview": "All versions of SheetJS CE through 0.19.2 are vulnerable to \"Prototype Pollution\" when reading specially crafted files. Workflows that do not read arbitrary files (for example, exporting data to spreadsheet files) are unaffected.\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.19.3 can be downloaded via https://cdn.sheetjs.com/.",
"reported_by": null,
"title": "Prototype Pollution in sheetJS",
"metadata": null,
"cves": [
"CVE-2023-30533"
],
"access": "public",
"severity": "high",
"module_name": "xlsx",
"vulnerable_versions": "<0.19.3",
"github_advisory_id": "GHSA-4r6h-8v6p-xvw6",
"recommendation": "None",
"patched_versions": "<0.0.0",
"updated": "2025-09-19T15:23:41.000Z",
"cvss": {
"score": 7.8,
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"
},
"cwe": [
"CWE-1321"
],
"url": "https://github.com/advisories/GHSA-4r6h-8v6p-xvw6"
},
"1108111": {
"findings": [
{
"version": "0.18.5",
"paths": [
".>xlsx"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://nvd.nist.gov/vuln/detail/CVE-2024-22363\n- https://cdn.sheetjs.com/advisories/CVE-2024-22363\n- https://cwe.mitre.org/data/definitions/1333.html\n- https://git.sheetjs.com/sheetjs/sheetjs/src/tag/v0.20.2\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-5pgg-2g8v-p4x9",
"created": "2024-04-05T06:30:46.000Z",
"id": 1108111,
"npm_advisory_id": null,
"overview": "SheetJS Community Edition before 0.20.2 is vulnerable.to Regular Expression Denial of Service (ReDoS).\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.20.2 can be downloaded via https://cdn.sheetjs.com/.",
"reported_by": null,
"title": "SheetJS Regular Expression Denial of Service (ReDoS)",
"metadata": null,
"cves": [
"CVE-2024-22363"
],
"access": "public",
"severity": "high",
"module_name": "xlsx",
"vulnerable_versions": "<0.20.2",
"github_advisory_id": "GHSA-5pgg-2g8v-p4x9",
"recommendation": "None",
"patched_versions": "<0.0.0",
"updated": "2025-09-19T15:23:26.000Z",
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"cwe": [
"CWE-1333"
],
"url": "https://github.com/advisories/GHSA-5pgg-2g8v-p4x9"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 2,
"critical": 0
},
"dependencies": 639,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 639
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sub2API - AI API Gateway</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
{
"name": "sub2api-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"typecheck": "vue-tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@lobehub/icons": "^4.0.2",
"@vueuse/core": "^10.7.0",
"axios": "^1.13.5",
"chart.js": "^4.4.1",
"dompurify": "^3.3.1",
"driver.js": "^1.4.0",
"file-saver": "^2.0.5",
"marked": "^17.0.1",
"pinia": "^2.1.7",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-chartjs": "^5.3.0",
"vue-draggable-plus": "^0.6.1",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/file-saver": "^2.0.7",
"@types/mdx": "^2.0.13",
"@types/node": "^20.10.5",
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "^2.1.9",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.25.0",
"jsdom": "^24.1.3",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "~5.6.0",
"vite": "^5.0.10",
"vite-plugin-checker": "^0.9.1",
"vitest": "^2.1.9",
"vue-tsc": "^2.2.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { RouterView, useRouter, useRoute } from 'vue-router'
import { onMounted, onBeforeUnmount, watch } from 'vue'
import Toast from '@/components/common/Toast.vue'
import NavigationProgress from '@/components/common/NavigationProgress.vue'
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const announcementStore = useAnnouncementStore()
/**
* Update favicon dynamically
* @param logoUrl - URL of the logo to use as favicon
*/
function updateFavicon(logoUrl: string) {
// Find existing favicon link or create new one
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.type = logoUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/x-icon'
link.href = logoUrl
}
// Watch for site settings changes and update favicon/title
watch(
() => appStore.siteLogo,
(newLogo) => {
if (newLogo) {
updateFavicon(newLogo)
}
},
{ immediate: true }
)
// Watch for authentication state and manage subscription data + announcements
function onVisibilityChange() {
if (document.visibilityState === 'visible' && authStore.isAuthenticated) {
announcementStore.fetchAnnouncements()
}
}
watch(
() => authStore.isAuthenticated,
(isAuthenticated, oldValue) => {
if (isAuthenticated) {
// User logged in: preload subscriptions and start polling
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to preload subscriptions:', error)
})
subscriptionStore.startPolling()
// Announcements: new login vs page refresh restore
if (oldValue === false) {
// New login: delay 3s then force fetch
setTimeout(() => announcementStore.fetchAnnouncements(true), 3000)
} else {
// Page refresh restore (oldValue was undefined)
announcementStore.fetchAnnouncements()
}
// Register visibility change listener
document.addEventListener('visibilitychange', onVisibilityChange)
} else {
// User logged out: clear data and stop polling
subscriptionStore.clear()
announcementStore.reset()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
},
{ immediate: true }
)
// Route change trigger (throttled by store)
router.afterEach(() => {
if (authStore.isAuthenticated) {
announcementStore.fetchAnnouncements()
}
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange)
})
onMounted(async () => {
// Check if setup is needed
try {
const status = await getSetupStatus()
if (status.needs_setup && route.path !== '/setup') {
router.replace('/setup')
return
}
} catch {
// If setup endpoint fails, assume normal mode and continue
}
// Load public settings into appStore (will be cached for other components)
await appStore.fetchPublicSettings()
})
</script>
<template>
<NavigationProgress />
<RouterView />
<Toast />
<AnnouncementPopup />
</template>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
const showError = vi.fn()
const showSuccess = vi.fn()
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
importData: vi.fn()
}
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
describe('ImportDataModal', () => {
beforeEach(() => {
showError.mockReset()
showSuccess.mockReset()
})
it('未选择文件时提示错误', async () => {
const wrapper = mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
await wrapper.find('form').trigger('submit')
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
})
it('无效 JSON 时提示解析失败', async () => {
const wrapper = mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
const input = wrapper.find('input[type="file"]')
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
Object.defineProperty(file, 'text', {
value: () => Promise.resolve('invalid json')
})
Object.defineProperty(input.element, 'files', {
value: [file]
})
await input.trigger('change')
await wrapper.find('form').trigger('submit')
await Promise.resolve()
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
})
})

View File

@@ -0,0 +1,478 @@
/**
* 导航集成测试
* 测试完整的页面导航流程、预加载和错误恢复机制
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createRouter, createWebHistory, type Router } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import { useNavigationLoadingState, _resetNavigationLoadingInstance } from '@/composables/useNavigationLoading'
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
// Mock 视图组件
const MockDashboard = defineComponent({
name: 'MockDashboard',
render() {
return h('div', { class: 'dashboard' }, 'Dashboard')
}
})
const MockKeys = defineComponent({
name: 'MockKeys',
render() {
return h('div', { class: 'keys' }, 'Keys')
}
})
const MockUsage = defineComponent({
name: 'MockUsage',
render() {
return h('div', { class: 'usage' }, 'Usage')
}
})
// Mock stores
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
isAuthenticated: true,
isAdmin: false,
isSimpleMode: false,
checkAuth: vi.fn()
})
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
siteName: 'Test Site'
})
}))
// 创建测试路由
function createTestRouter(): Router {
return createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard,
meta: { requiresAuth: true, title: 'Dashboard' }
},
{
path: '/keys',
name: 'Keys',
component: MockKeys,
meta: { requiresAuth: true, title: 'Keys' }
},
{
path: '/usage',
name: 'Usage',
component: MockUsage,
meta: { requiresAuth: true, title: 'Usage' }
}
]
})
}
// 测试用 App 组件
const TestApp = defineComponent({
name: 'TestApp',
setup() {
return () => h('div', { id: 'app' }, [h('router-view')])
}
})
describe('Navigation Integration Tests', () => {
let router: Router
let originalRequestIdleCallback: typeof window.requestIdleCallback
let originalCancelIdleCallback: typeof window.cancelIdleCallback
beforeEach(() => {
// 设置 Pinia
setActivePinia(createPinia())
// 重置导航加载状态
_resetNavigationLoadingInstance()
// 创建新的路由实例
router = createTestRouter()
// Mock requestIdleCallback
originalRequestIdleCallback = window.requestIdleCallback
originalCancelIdleCallback = window.cancelIdleCallback
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
return id
})
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
})
afterEach(() => {
vi.restoreAllMocks()
window.requestIdleCallback = originalRequestIdleCallback
window.cancelIdleCallback = originalCancelIdleCallback
})
describe('完整页面导航流程', () => {
it('导航时应该触发加载状态变化', async () => {
const navigationLoading = useNavigationLoadingState()
// 初始状态
expect(navigationLoading.isLoading.value).toBe(false)
// 挂载应用
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
// 等待路由初始化
await router.isReady()
await flushPromises()
// 导航到 /dashboard
await router.push('/dashboard')
await flushPromises()
await nextTick()
// 导航结束后状态应该重置
expect(navigationLoading.isLoading.value).toBe(false)
wrapper.unmount()
})
it('导航到新页面应该正确渲染组件', async () => {
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
await flushPromises()
await nextTick()
// 检查当前路由
expect(router.currentRoute.value.path).toBe('/dashboard')
wrapper.unmount()
})
it('连续快速导航应该正确处理路由状态', async () => {
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
// 快速连续导航
router.push('/keys')
router.push('/usage')
router.push('/dashboard')
await flushPromises()
await nextTick()
// 应该最终停在 /dashboard
expect(router.currentRoute.value.path).toBe('/dashboard')
wrapper.unmount()
})
})
describe('路由预加载', () => {
it('导航后应该触发相关路由预加载', async () => {
const routePrefetch = useRoutePrefetch()
const triggerSpy = vi.spyOn(routePrefetch, 'triggerPrefetch')
// 设置 afterEach 守卫
router.afterEach((to) => {
routePrefetch.triggerPrefetch(to)
})
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
await flushPromises()
// 应该触发预加载
expect(triggerSpy).toHaveBeenCalled()
wrapper.unmount()
})
it('已预加载的路由不应重复预加载', async () => {
const routePrefetch = useRoutePrefetch()
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
await flushPromises()
// 手动触发预加载
routePrefetch.triggerPrefetch(router.currentRoute.value)
await new Promise((resolve) => setTimeout(resolve, 100))
const prefetchedCount = routePrefetch.prefetchedRoutes.value.size
// 再次触发相同路由预加载
routePrefetch.triggerPrefetch(router.currentRoute.value)
await new Promise((resolve) => setTimeout(resolve, 100))
// 预加载数量不应增加
expect(routePrefetch.prefetchedRoutes.value.size).toBe(prefetchedCount)
wrapper.unmount()
})
it('路由变化时应取消之前的预加载任务', async () => {
const routePrefetch = useRoutePrefetch()
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
// 触发预加载
routePrefetch.triggerPrefetch(router.currentRoute.value)
// 立即导航到新路由(这会在内部调用 cancelPendingPrefetch
routePrefetch.triggerPrefetch({ path: '/keys' } as any)
// 由于 triggerPrefetch 内部调用 cancelPendingPrefetch检查是否有预加载被正确管理
expect(routePrefetch.prefetchedRoutes.value.size).toBeLessThanOrEqual(2)
wrapper.unmount()
})
})
describe('Chunk 加载错误恢复', () => {
it('chunk 加载失败应该被正确捕获', async () => {
const errorHandler = vi.fn()
// 创建带错误处理的路由
const errorRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/error-page',
name: 'ErrorPage',
// 模拟加载失败的组件
component: () => Promise.reject(new Error('Failed to fetch dynamically imported module'))
}
]
})
errorRouter.onError(errorHandler)
const wrapper = mount(TestApp, {
global: {
plugins: [errorRouter]
}
})
await errorRouter.isReady()
await errorRouter.push('/dashboard')
await flushPromises()
// 尝试导航到会失败的页面
try {
await errorRouter.push('/error-page')
} catch {
// 预期会失败
}
await flushPromises()
// 错误处理器应该被调用
expect(errorHandler).toHaveBeenCalled()
wrapper.unmount()
})
it('chunk 加载错误应该包含正确的错误信息', async () => {
let capturedError: Error | null = null
const errorRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/chunk-error',
name: 'ChunkError',
component: () => {
const error = new Error('Loading chunk failed')
error.name = 'ChunkLoadError'
return Promise.reject(error)
}
}
]
})
errorRouter.onError((error) => {
capturedError = error
})
const wrapper = mount(TestApp, {
global: {
plugins: [errorRouter]
}
})
await errorRouter.isReady()
try {
await errorRouter.push('/chunk-error')
} catch {
// 预期会失败
}
await flushPromises()
expect(capturedError).not.toBeNull()
expect(capturedError!.name).toBe('ChunkLoadError')
wrapper.unmount()
})
})
describe('导航状态管理', () => {
it('导航开始时 isLoading 应该变为 true', async () => {
const navigationLoading = useNavigationLoadingState()
// 创建一个延迟加载的组件来模拟真实场景
const DelayedComponent = defineComponent({
name: 'DelayedComponent',
async setup() {
await new Promise((resolve) => setTimeout(resolve, 50))
return () => h('div', 'Delayed')
}
})
const delayRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/delayed',
name: 'Delayed',
component: DelayedComponent
}
]
})
// 设置导航守卫
delayRouter.beforeEach(() => {
navigationLoading.startNavigation()
})
delayRouter.afterEach(() => {
navigationLoading.endNavigation()
})
const wrapper = mount(TestApp, {
global: {
plugins: [delayRouter]
}
})
await delayRouter.isReady()
await delayRouter.push('/dashboard')
await flushPromises()
// 导航结束后 isLoading 应该为 false
expect(navigationLoading.isLoading.value).toBe(false)
wrapper.unmount()
})
it('导航取消时应该正确重置状态', async () => {
const navigationLoading = useNavigationLoadingState()
const testRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/keys',
name: 'Keys',
component: MockKeys,
beforeEnter: (_to, _from, next) => {
// 模拟导航取消
next(false)
}
}
]
})
testRouter.beforeEach(() => {
navigationLoading.startNavigation()
})
testRouter.afterEach(() => {
navigationLoading.endNavigation()
})
const wrapper = mount(TestApp, {
global: {
plugins: [testRouter]
}
})
await testRouter.isReady()
await testRouter.push('/dashboard')
await flushPromises()
// 尝试导航到被取消的路由
await testRouter.push('/keys').catch(() => {})
await flushPromises()
// 导航被取消后,状态应该被重置
// 注意:由于 afterEach 仍然会被调用isLoading 应该为 false
expect(navigationLoading.isLoading.value).toBe(false)
wrapper.unmount()
})
})
})

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
const showError = vi.fn()
const showSuccess = vi.fn()
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
proxies: {
importData: vi.fn()
}
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
describe('Proxy ImportDataModal', () => {
beforeEach(() => {
showError.mockReset()
showSuccess.mockReset()
})
it('未选择文件时提示错误', async () => {
const wrapper = mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
await wrapper.find('form').trigger('submit')
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportSelectFile')
})
it('无效 JSON 时提示解析失败', async () => {
const wrapper = mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
const input = wrapper.find('input[type="file"]')
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
Object.defineProperty(file, 'text', {
value: () => Promise.resolve('invalid json')
})
Object.defineProperty(input.element, 'files', {
value: [file]
})
await input.trigger('change')
await wrapper.find('form').trigger('submit')
await Promise.resolve()
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed')
})
})

View File

@@ -0,0 +1,45 @@
/**
* Vitest 测试环境设置
* 提供全局 mock 和测试工具
*/
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
// Mock requestIdleCallback (Safari < 15 不支持)
if (typeof globalThis.requestIdleCallback === 'undefined') {
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
return window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 1)
}) as unknown as typeof requestIdleCallback
}
if (typeof globalThis.cancelIdleCallback === 'undefined') {
globalThis.cancelIdleCallback = ((id: number) => {
window.clearTimeout(id)
}) as unknown as typeof cancelIdleCallback
}
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
// Mock ResizeObserver
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
// Vue Test Utils 全局配置
config.global.stubs = {
// 可以在这里添加全局 stub
}
// 设置全局测试超时
vi.setConfig({ testTimeout: 10000 })

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios'
import type { AxiosInstance } from 'axios'
// 需要在导入 client 之前设置 mock
vi.mock('@/i18n', () => ({
getLocale: () => 'zh-CN',
}))
describe('API Client', () => {
let apiClient: AxiosInstance
beforeEach(async () => {
localStorage.clear()
// 每次测试重新导入以获取干净的模块状态
vi.resetModules()
const mod = await import('@/api/client')
apiClient = mod.apiClient
})
afterEach(() => {
vi.restoreAllMocks()
})
// --- 请求拦截器 ---
describe('请求拦截器', () => {
it('自动附加 Authorization 头', async () => {
localStorage.setItem('auth_token', 'my-jwt-token')
// 拦截实际请求
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('Authorization')).toBe('Bearer my-jwt-token')
})
it('无 token 时不附加 Authorization 头', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('Authorization')).toBeFalsy()
})
it('GET 请求自动附加 timezone 参数', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.params).toHaveProperty('timezone')
})
it('POST 请求不附加 timezone 参数', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.post('/test', { foo: 'bar' })
const config = adapter.mock.calls[0][0]
expect(config.params?.timezone).toBeUndefined()
})
})
// --- 响应拦截器 ---
describe('响应拦截器', () => {
it('code=0 时解包 data 字段', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: { name: 'test' }, message: 'ok' },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
const response = await apiClient.get('/test')
expect(response.data).toEqual({ name: 'test' })
})
it('code!=0 时拒绝并返回结构化错误', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 1001, message: '参数错误', data: null },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toEqual(
expect.objectContaining({
code: 1001,
message: '参数错误',
})
)
})
})
// --- 401 Token 刷新 ---
describe('401 Token 刷新', () => {
it('无 refresh_token 时 401 清除 localStorage', async () => {
localStorage.setItem('auth_token', 'expired-token')
// 不设置 refresh_token
// Mock window.location
const originalLocation = window.location
Object.defineProperty(window, 'location', {
value: { ...originalLocation, pathname: '/dashboard', href: '/dashboard' },
writable: true,
})
const adapter = vi.fn().mockRejectedValue({
response: {
status: 401,
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
},
config: {
url: '/test',
headers: { Authorization: 'Bearer expired-token' },
},
code: 'ERR_BAD_REQUEST',
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toBeDefined()
expect(localStorage.getItem('auth_token')).toBeNull()
// 恢复 location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
})
})
})
// --- 网络错误 ---
describe('网络错误', () => {
it('网络错误返回 status 0 的错误', async () => {
const adapter = vi.fn().mockRejectedValue({
code: 'ERR_NETWORK',
message: 'Network Error',
config: { url: '/test' },
// 没有 response
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toEqual(
expect.objectContaining({
status: 0,
message: 'Network error. Please check your connection.',
})
)
})
})
// --- 请求取消 ---
describe('请求取消', () => {
it('取消的请求保持原始取消错误', async () => {
const source = axios.CancelToken.source()
const adapter = vi.fn().mockRejectedValue(
new axios.Cancel('Operation canceled')
)
apiClient.defaults.adapter = adapter
await expect(
apiClient.get('/test', { cancelToken: source.token })
).rejects.toBeDefined()
})
})
})

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import {
normalizeGenerationListResponse,
normalizeModelFamiliesResponse
} from '../sora'
describe('sora api normalizers', () => {
it('normalizes generation list from data shape', () => {
const result = normalizeGenerationListResponse({
data: [{ id: 1, status: 'pending' }],
total: 9,
page: 2
})
expect(result.data).toHaveLength(1)
expect(result.total).toBe(9)
expect(result.page).toBe(2)
})
it('normalizes generation list from items shape', () => {
const result = normalizeGenerationListResponse({
items: [{ id: 1, status: 'completed' }],
total: 1
})
expect(result.data).toHaveLength(1)
expect(result.total).toBe(1)
expect(result.page).toBe(1)
})
it('falls back to empty generation list on invalid payload', () => {
const result = normalizeGenerationListResponse(null)
expect(result).toEqual({ data: [], total: 0, page: 1 })
})
it('normalizes family model payload', () => {
const result = normalizeModelFamiliesResponse({
data: [
{
id: 'sora2',
name: 'Sora 2',
type: 'video',
orientations: ['landscape', 'portrait'],
durations: [10, 15]
}
]
})
expect(result).toHaveLength(1)
expect(result[0].id).toBe('sora2')
expect(result[0].orientations).toEqual(['landscape', 'portrait'])
expect(result[0].durations).toEqual([10, 15])
})
it('normalizes legacy flat model list into families', () => {
const result = normalizeModelFamiliesResponse({
items: [
{ id: 'sora2-landscape-10s', type: 'video' },
{ id: 'sora2-portrait-15s', type: 'video' },
{ id: 'gpt-image-square', type: 'image' }
]
})
const sora2 = result.find((m) => m.id === 'sora2')
expect(sora2).toBeTruthy()
expect(sora2?.orientations).toEqual(['landscape', 'portrait'])
expect(sora2?.durations).toEqual([10, 15])
const image = result.find((m) => m.id === 'gpt-image')
expect(image).toBeTruthy()
expect(image?.type).toBe('image')
expect(image?.orientations).toEqual(['square'])
})
it('falls back to empty families on invalid payload', () => {
expect(normalizeModelFamiliesResponse(undefined)).toEqual([])
expect(normalizeModelFamiliesResponse({})).toEqual([])
})
})

View File

@@ -0,0 +1,660 @@
/**
* Admin Accounts API endpoints
* Handles AI platform account management for administrators
*/
import { apiClient } from '../client'
import type {
Account,
CreateAccountRequest,
UpdateAccountRequest,
PaginatedResponse,
AccountUsageInfo,
WindowStats,
ClaudeModel,
AccountUsageStatsResponse,
TempUnschedulableStatus,
AdminDataPayload,
AdminDataImportResult,
CheckMixedChannelRequest,
CheckMixedChannelResponse
} from '@/types'
/**
* List all accounts with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters
* @returns Paginated list of accounts
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
platform?: string
type?: string
status?: string
group?: string
search?: string
lite?: string
},
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<Account>> {
const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
params: {
page,
page_size: pageSize,
...filters
},
signal: options?.signal
})
return data
}
export interface AccountListWithEtagResult {
notModified: boolean
etag: string | null
data: PaginatedResponse<Account> | null
}
export async function listWithEtag(
page: number = 1,
pageSize: number = 20,
filters?: {
platform?: string
type?: string
status?: string
search?: string
lite?: string
},
options?: {
signal?: AbortSignal
etag?: string | null
}
): Promise<AccountListWithEtagResult> {
const headers: Record<string, string> = {}
if (options?.etag) {
headers['If-None-Match'] = options.etag
}
const response = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
params: {
page,
page_size: pageSize,
...filters
},
headers,
signal: options?.signal,
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
})
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
if (response.status === 304) {
return {
notModified: true,
etag: etagHeader,
data: null
}
}
return {
notModified: false,
etag: etagHeader,
data: response.data
}
}
/**
* Get account by ID
* @param id - Account ID
* @returns Account details
*/
export async function getById(id: number): Promise<Account> {
const { data } = await apiClient.get<Account>(`/admin/accounts/${id}`)
return data
}
/**
* Create new account
* @param accountData - Account data
* @returns Created account
*/
export async function create(accountData: CreateAccountRequest): Promise<Account> {
const { data } = await apiClient.post<Account>('/admin/accounts', accountData)
return data
}
/**
* Update account
* @param id - Account ID
* @param updates - Fields to update
* @returns Updated account
*/
export async function update(id: number, updates: UpdateAccountRequest): Promise<Account> {
const { data } = await apiClient.put<Account>(`/admin/accounts/${id}`, updates)
return data
}
/**
* Check mixed-channel risk for account-group binding.
*/
export async function checkMixedChannelRisk(
payload: CheckMixedChannelRequest
): Promise<CheckMixedChannelResponse> {
const { data } = await apiClient.post<CheckMixedChannelResponse>('/admin/accounts/check-mixed-channel', payload)
return data
}
/**
* Delete account
* @param id - Account ID
* @returns Success confirmation
*/
export async function deleteAccount(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/accounts/${id}`)
return data
}
/**
* Toggle account status
* @param id - Account ID
* @param status - New status
* @returns Updated account
*/
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Account> {
return update(id, { status })
}
/**
* Test account connectivity
* @param id - Account ID
* @returns Test result
*/
export async function testAccount(id: number): Promise<{
success: boolean
message: string
latency_ms?: number
}> {
const { data } = await apiClient.post<{
success: boolean
message: string
latency_ms?: number
}>(`/admin/accounts/${id}/test`)
return data
}
/**
* Refresh account credentials
* @param id - Account ID
* @returns Updated account
*/
export async function refreshCredentials(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/refresh`)
return data
}
/**
* Get account usage statistics
* @param id - Account ID
* @param days - Number of days (default: 30)
* @returns Account usage statistics with history, summary, and models
*/
export async function getStats(id: number, days: number = 30): Promise<AccountUsageStatsResponse> {
const { data } = await apiClient.get<AccountUsageStatsResponse>(`/admin/accounts/${id}/stats`, {
params: { days }
})
return data
}
/**
* Clear account error
* @param id - Account ID
* @returns Updated account
*/
export async function clearError(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/clear-error`)
return data
}
/**
* Get account usage information (5h/7d window)
* @param id - Account ID
* @returns Account usage info
*/
export async function getUsage(id: number): Promise<AccountUsageInfo> {
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`)
return data
}
/**
* Clear account rate limit status
* @param id - Account ID
* @returns Updated account
*/
export async function clearRateLimit(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(
`/admin/accounts/${id}/clear-rate-limit`
)
return data
}
/**
* Recover account runtime state in one call
* @param id - Account ID
* @returns Updated account
*/
export async function recoverState(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/recover-state`)
return data
}
/**
* Reset account quota usage
* @param id - Account ID
* @returns Updated account
*/
export async function resetAccountQuota(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(
`/admin/accounts/${id}/reset-quota`
)
return data
}
/**
* Get temporary unschedulable status
* @param id - Account ID
* @returns Status with detail state if active
*/
export async function getTempUnschedulableStatus(id: number): Promise<TempUnschedulableStatus> {
const { data } = await apiClient.get<TempUnschedulableStatus>(
`/admin/accounts/${id}/temp-unschedulable`
)
return data
}
/**
* Reset temporary unschedulable status
* @param id - Account ID
* @returns Success confirmation
*/
export async function resetTempUnschedulable(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(
`/admin/accounts/${id}/temp-unschedulable`
)
return data
}
/**
* Generate OAuth authorization URL
* @param endpoint - API endpoint path
* @param config - Proxy configuration
* @returns Auth URL and session ID
*/
export async function generateAuthUrl(
endpoint: string,
config: { proxy_id?: number }
): Promise<{ auth_url: string; session_id: string }> {
const { data } = await apiClient.post<{ auth_url: string; session_id: string }>(endpoint, config)
return data
}
/**
* Exchange authorization code for tokens
* @param endpoint - API endpoint path
* @param exchangeData - Session ID, code, and optional proxy config
* @returns Token information
*/
export async function exchangeCode(
endpoint: string,
exchangeData: { session_id: string; code: string; state?: string; proxy_id?: number }
): Promise<Record<string, unknown>> {
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData)
return data
}
/**
* Batch create accounts
* @param accounts - Array of account data
* @returns Results of batch creation
*/
export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{
success: number
failed: number
results: Array<{ success: boolean; account?: Account; error?: string }>
}> {
const { data } = await apiClient.post<{
success: number
failed: number
results: Array<{ success: boolean; account?: Account; error?: string }>
}>('/admin/accounts/batch', { accounts })
return data
}
/**
* Batch update credentials fields for multiple accounts
* @param request - Batch update request containing account IDs, field name, and value
* @returns Results of batch update
*/
export async function batchUpdateCredentials(request: {
account_ids: number[]
field: string
value: any
}): Promise<{
success: number
failed: number
results: Array<{ account_id: number; success: boolean; error?: string }>
}> {
const { data } = await apiClient.post<{
success: number
failed: number
results: Array<{ account_id: number; success: boolean; error?: string }>
}>('/admin/accounts/batch-update-credentials', request)
return data
}
/**
* Bulk update multiple accounts
* @param accountIds - Array of account IDs
* @param updates - Fields to update
* @returns Success confirmation
*/
export async function bulkUpdate(
accountIds: number[],
updates: Record<string, unknown>
): Promise<{
success: number
failed: number
success_ids?: number[]
failed_ids?: number[]
results: Array<{ account_id: number; success: boolean; error?: string }>
}> {
const { data } = await apiClient.post<{
success: number
failed: number
success_ids?: number[]
failed_ids?: number[]
results: Array<{ account_id: number; success: boolean; error?: string }>
}>('/admin/accounts/bulk-update', {
account_ids: accountIds,
...updates
})
return data
}
/**
* Get account today statistics
* @param id - Account ID
* @returns Today's stats (requests, tokens, cost)
*/
export async function getTodayStats(id: number): Promise<WindowStats> {
const { data } = await apiClient.get<WindowStats>(`/admin/accounts/${id}/today-stats`)
return data
}
export interface BatchTodayStatsResponse {
stats: Record<string, WindowStats>
}
/**
* 批量获取多个账号的今日统计
* @param accountIds - 账号 ID 列表
* @returns 以账号 ID字符串为键的统计映射
*/
export async function getBatchTodayStats(accountIds: number[]): Promise<BatchTodayStatsResponse> {
const { data } = await apiClient.post<BatchTodayStatsResponse>('/admin/accounts/today-stats/batch', {
account_ids: accountIds
})
return data
}
/**
* Set account schedulable status
* @param id - Account ID
* @param schedulable - Whether the account should participate in scheduling
* @returns Updated account
*/
export async function setSchedulable(id: number, schedulable: boolean): Promise<Account> {
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/schedulable`, {
schedulable
})
return data
}
/**
* Get available models for an account
* @param id - Account ID
* @returns List of available models for this account
*/
export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
const { data } = await apiClient.get<ClaudeModel[]>(`/admin/accounts/${id}/models`)
return data
}
export interface CRSPreviewAccount {
crs_account_id: string
kind: string
name: string
platform: string
type: string
}
export interface PreviewFromCRSResult {
new_accounts: CRSPreviewAccount[]
existing_accounts: CRSPreviewAccount[]
}
export async function previewFromCrs(params: {
base_url: string
username: string
password: string
}): Promise<PreviewFromCRSResult> {
const { data } = await apiClient.post<PreviewFromCRSResult>('/admin/accounts/sync/crs/preview', params)
return data
}
export async function syncFromCrs(params: {
base_url: string
username: string
password: string
sync_proxies?: boolean
selected_account_ids?: string[]
}): Promise<{
created: number
updated: number
skipped: number
failed: number
items: Array<{
crs_account_id: string
kind: string
name: string
action: string
error?: string
}>
}> {
const { data } = await apiClient.post<{
created: number
updated: number
skipped: number
failed: number
items: Array<{
crs_account_id: string
kind: string
name: string
action: string
error?: string
}>
}>('/admin/accounts/sync/crs', params)
return data
}
export async function exportData(options?: {
ids?: number[]
filters?: {
platform?: string
type?: string
status?: string
search?: string
}
includeProxies?: boolean
}): Promise<AdminDataPayload> {
const params: Record<string, string> = {}
if (options?.ids && options.ids.length > 0) {
params.ids = options.ids.join(',')
} else if (options?.filters) {
const { platform, type, status, search } = options.filters
if (platform) params.platform = platform
if (type) params.type = type
if (status) params.status = status
if (search) params.search = search
}
if (options?.includeProxies === false) {
params.include_proxies = 'false'
}
const { data } = await apiClient.get<AdminDataPayload>('/admin/accounts/data', { params })
return data
}
export async function importData(payload: {
data: AdminDataPayload
skip_default_group_bind?: boolean
}): Promise<AdminDataImportResult> {
const { data } = await apiClient.post<AdminDataImportResult>('/admin/accounts/data', {
data: payload.data,
skip_default_group_bind: payload.skip_default_group_bind
})
return data
}
/**
* Get Antigravity default model mapping from backend
* @returns Default model mapping (from -> to)
*/
export async function getAntigravityDefaultModelMapping(): Promise<Record<string, string>> {
const { data } = await apiClient.get<Record<string, string>>(
'/admin/accounts/antigravity/default-model-mapping'
)
return data
}
/**
* Refresh OpenAI token using refresh token
* @param refreshToken - The refresh token
* @param proxyId - Optional proxy ID
* @returns Token information including access_token, email, etc.
*/
export async function refreshOpenAIToken(
refreshToken: string,
proxyId?: number | null,
endpoint: string = '/admin/openai/refresh-token'
): Promise<Record<string, unknown>> {
const payload: { refresh_token: string; proxy_id?: number } = {
refresh_token: refreshToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data
}
/**
* Validate Sora session token and exchange to access token
* @param sessionToken - Sora session token
* @param proxyId - Optional proxy ID
* @param endpoint - API endpoint path
* @returns Token information including access_token
*/
export async function validateSoraSessionToken(
sessionToken: string,
proxyId?: number | null,
endpoint: string = '/admin/sora/st2at'
): Promise<Record<string, unknown>> {
const payload: { session_token: string; proxy_id?: number } = {
session_token: sessionToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data
}
/**
* Batch operation result type
*/
export interface BatchOperationResult {
total: number
success: number
failed: number
errors?: Array<{ account_id: number; error: string }>
warnings?: Array<{ account_id: number; warning: string }>
}
/**
* Batch clear account errors
* @param accountIds - Array of account IDs
* @returns Batch operation result
*/
export async function batchClearError(accountIds: number[]): Promise<BatchOperationResult> {
const { data } = await apiClient.post<BatchOperationResult>('/admin/accounts/batch-clear-error', {
account_ids: accountIds
})
return data
}
/**
* Batch refresh account credentials
* @param accountIds - Array of account IDs
* @returns Batch operation result
*/
export async function batchRefresh(accountIds: number[]): Promise<BatchOperationResult> {
const { data } = await apiClient.post<BatchOperationResult>('/admin/accounts/batch-refresh', {
account_ids: accountIds,
}, {
timeout: 120000 // 120s timeout for large batch refreshes
})
return data
}
export const accountsAPI = {
list,
listWithEtag,
getById,
create,
update,
checkMixedChannelRisk,
delete: deleteAccount,
toggleStatus,
testAccount,
refreshCredentials,
getStats,
clearError,
getUsage,
getTodayStats,
getBatchTodayStats,
clearRateLimit,
recoverState,
resetAccountQuota,
getTempUnschedulableStatus,
resetTempUnschedulable,
setSchedulable,
getAvailableModels,
generateAuthUrl,
exchangeCode,
refreshOpenAIToken,
validateSoraSessionToken,
batchCreate,
batchUpdateCredentials,
bulkUpdate,
previewFromCrs,
syncFromCrs,
exportData,
importData,
getAntigravityDefaultModelMapping,
batchClearError,
batchRefresh
}
export default accountsAPI

View File

@@ -0,0 +1,71 @@
/**
* Admin Announcements API endpoints
*/
import { apiClient } from '../client'
import type {
Announcement,
AnnouncementUserReadStatus,
BasePaginationResponse,
CreateAnnouncementRequest,
UpdateAnnouncementRequest
} from '@/types'
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: string
search?: string
}
): Promise<BasePaginationResponse<Announcement>> {
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
params: { page, page_size: pageSize, ...filters }
})
return data
}
export async function getById(id: number): Promise<Announcement> {
const { data } = await apiClient.get<Announcement>(`/admin/announcements/${id}`)
return data
}
export async function create(request: CreateAnnouncementRequest): Promise<Announcement> {
const { data } = await apiClient.post<Announcement>('/admin/announcements', request)
return data
}
export async function update(id: number, request: UpdateAnnouncementRequest): Promise<Announcement> {
const { data } = await apiClient.put<Announcement>(`/admin/announcements/${id}`, request)
return data
}
export async function deleteAnnouncement(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/announcements/${id}`)
return data
}
export async function getReadStatus(
id: number,
page: number = 1,
pageSize: number = 20,
search: string = ''
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
`/admin/announcements/${id}/read-status`,
{ params: { page, page_size: pageSize, search } }
)
return data
}
const announcementsAPI = {
list,
getById,
create,
update,
delete: deleteAnnouncement,
getReadStatus
}
export default announcementsAPI

View File

@@ -0,0 +1,70 @@
/**
* Admin Antigravity API endpoints
* Handles Antigravity (Google Cloud AI Companion) OAuth flows for administrators
*/
import { apiClient } from '../client'
export interface AntigravityAuthUrlResponse {
auth_url: string
session_id: string
state: string
}
export interface AntigravityAuthUrlRequest {
proxy_id?: number
}
export interface AntigravityExchangeCodeRequest {
session_id: string
state: string
code: string
proxy_id?: number
}
export interface AntigravityTokenInfo {
access_token?: string
refresh_token?: string
token_type?: string
expires_at?: number | string
expires_in?: number
project_id?: string
email?: string
[key: string]: unknown
}
export async function generateAuthUrl(
payload: AntigravityAuthUrlRequest
): Promise<AntigravityAuthUrlResponse> {
const { data } = await apiClient.post<AntigravityAuthUrlResponse>(
'/admin/antigravity/oauth/auth-url',
payload
)
return data
}
export async function exchangeCode(
payload: AntigravityExchangeCodeRequest
): Promise<AntigravityTokenInfo> {
const { data } = await apiClient.post<AntigravityTokenInfo>(
'/admin/antigravity/oauth/exchange-code',
payload
)
return data
}
export async function refreshAntigravityToken(
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo> {
const payload: Record<string, any> = { refresh_token: refreshToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<AntigravityTokenInfo>(
'/admin/antigravity/oauth/refresh-token',
payload
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }

View File

@@ -0,0 +1,33 @@
/**
* Admin API Keys API endpoints
* Handles API key management for administrators
*/
import { apiClient } from '../client'
import type { ApiKey } from '@/types'
export interface UpdateApiKeyGroupResult {
api_key: ApiKey
auto_granted_group_access: boolean
granted_group_id?: number
granted_group_name?: string
}
/**
* Update an API key's group binding
* @param id - API Key ID
* @param groupId - Group ID (0 to unbind, positive to bind, null/undefined to skip)
* @returns Updated API key with auto-grant info
*/
export async function updateApiKeyGroup(id: number, groupId: number | null): Promise<UpdateApiKeyGroupResult> {
const { data } = await apiClient.put<UpdateApiKeyGroupResult>(`/admin/api-keys/${id}`, {
group_id: groupId === null ? 0 : groupId
})
return data
}
export const apiKeysAPI = {
updateApiKeyGroup
}
export default apiKeysAPI

View File

@@ -0,0 +1,114 @@
import { apiClient } from '../client'
export interface BackupS3Config {
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
}
export interface BackupScheduleConfig {
enabled: boolean
cron_expr: string
retain_days: number
retain_count: number
}
export interface BackupRecord {
id: string
status: 'pending' | 'running' | 'completed' | 'failed'
backup_type: string
file_name: string
s3_key: string
size_bytes: number
triggered_by: string
error_message?: string
started_at: string
finished_at?: string
expires_at?: string
}
export interface CreateBackupRequest {
expire_days?: number
}
export interface TestS3Response {
ok: boolean
message: string
}
// S3 Config
export async function getS3Config(): Promise<BackupS3Config> {
const { data } = await apiClient.get<BackupS3Config>('/admin/backups/s3-config')
return data
}
export async function updateS3Config(config: BackupS3Config): Promise<BackupS3Config> {
const { data } = await apiClient.put<BackupS3Config>('/admin/backups/s3-config', config)
return data
}
export async function testS3Connection(config: BackupS3Config): Promise<TestS3Response> {
const { data } = await apiClient.post<TestS3Response>('/admin/backups/s3-config/test', config)
return data
}
// Schedule
export async function getSchedule(): Promise<BackupScheduleConfig> {
const { data } = await apiClient.get<BackupScheduleConfig>('/admin/backups/schedule')
return data
}
export async function updateSchedule(config: BackupScheduleConfig): Promise<BackupScheduleConfig> {
const { data } = await apiClient.put<BackupScheduleConfig>('/admin/backups/schedule', config)
return data
}
// Backup operations
export async function createBackup(req?: CreateBackupRequest): Promise<BackupRecord> {
const { data } = await apiClient.post<BackupRecord>('/admin/backups', req || {}, { timeout: 600000 })
return data
}
export async function listBackups(): Promise<{ items: BackupRecord[] }> {
const { data } = await apiClient.get<{ items: BackupRecord[] }>('/admin/backups')
return data
}
export async function getBackup(id: string): Promise<BackupRecord> {
const { data } = await apiClient.get<BackupRecord>(`/admin/backups/${id}`)
return data
}
export async function deleteBackup(id: string): Promise<void> {
await apiClient.delete(`/admin/backups/${id}`)
}
export async function getDownloadURL(id: string): Promise<{ url: string }> {
const { data } = await apiClient.get<{ url: string }>(`/admin/backups/${id}/download-url`)
return data
}
// Restore
export async function restoreBackup(id: string, password: string): Promise<void> {
await apiClient.post(`/admin/backups/${id}/restore`, { password }, { timeout: 600000 })
}
export const backupAPI = {
getS3Config,
updateS3Config,
testS3Connection,
getSchedule,
updateSchedule,
createBackup,
listBackups,
getBackup,
deleteBackup,
getDownloadURL,
restoreBackup,
}
export default backupAPI

View File

@@ -0,0 +1,299 @@
/**
* Admin Dashboard API endpoints
* Provides system-wide statistics and metrics
*/
import { apiClient } from '../client'
import type {
DashboardStats,
TrendDataPoint,
ModelStat,
GroupStat,
ApiKeyUsageTrendPoint,
UserUsageTrendPoint,
UserSpendingRankingResponse,
UsageRequestType
} from '@/types'
/**
* Get dashboard statistics
* @returns Dashboard statistics including users, keys, accounts, and token usage
*/
export async function getStats(): Promise<DashboardStats> {
const { data } = await apiClient.get<DashboardStats>('/admin/dashboard/stats')
return data
}
/**
* Get real-time metrics
* @returns Real-time system metrics
*/
export async function getRealtimeMetrics(): Promise<{
active_requests: number
requests_per_minute: number
average_response_time: number
error_rate: number
}> {
const { data } = await apiClient.get<{
active_requests: number
requests_per_minute: number
average_response_time: number
error_rate: number
}>('/admin/dashboard/realtime')
return data
}
export interface TrendParams {
start_date?: string
end_date?: string
granularity?: 'day' | 'hour'
user_id?: number
api_key_id?: number
model?: string
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
export interface TrendResponse {
trend: TrendDataPoint[]
start_date: string
end_date: string
granularity: string
}
/**
* Get usage trend data
* @param params - Query parameters for filtering
* @returns Usage trend data
*/
export async function getUsageTrend(params?: TrendParams): Promise<TrendResponse> {
const { data } = await apiClient.get<TrendResponse>('/admin/dashboard/trend', { params })
return data
}
export interface ModelStatsParams {
start_date?: string
end_date?: string
user_id?: number
api_key_id?: number
model?: string
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
export interface ModelStatsResponse {
models: ModelStat[]
start_date: string
end_date: string
}
/**
* Get model usage statistics
* @param params - Query parameters for filtering
* @returns Model usage statistics
*/
export async function getModelStats(params?: ModelStatsParams): Promise<ModelStatsResponse> {
const { data } = await apiClient.get<ModelStatsResponse>('/admin/dashboard/models', { params })
return data
}
export interface GroupStatsParams {
start_date?: string
end_date?: string
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
export interface GroupStatsResponse {
groups: GroupStat[]
start_date: string
end_date: string
}
export interface DashboardSnapshotV2Params extends TrendParams {
include_stats?: boolean
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
include_users_trend?: boolean
users_trend_limit?: number
}
export interface DashboardSnapshotV2Stats extends DashboardStats {
uptime: number
}
export interface DashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
stats?: DashboardSnapshotV2Stats
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
users_trend?: UserUsageTrendPoint[]
}
/**
* Get group usage statistics
* @param params - Query parameters for filtering
* @returns Group usage statistics
*/
export async function getGroupStats(params?: GroupStatsParams): Promise<GroupStatsResponse> {
const { data } = await apiClient.get<GroupStatsResponse>('/admin/dashboard/groups', { params })
return data
}
/**
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
*/
export async function getSnapshotV2(params?: DashboardSnapshotV2Params): Promise<DashboardSnapshotV2Response> {
const { data } = await apiClient.get<DashboardSnapshotV2Response>('/admin/dashboard/snapshot-v2', {
params
})
return data
}
export interface ApiKeyTrendParams extends TrendParams {
limit?: number
}
export interface ApiKeyTrendResponse {
trend: ApiKeyUsageTrendPoint[]
start_date: string
end_date: string
granularity: string
}
/**
* Get API key usage trend data
* @param params - Query parameters for filtering
* @returns API key usage trend data
*/
export async function getApiKeyUsageTrend(
params?: ApiKeyTrendParams
): Promise<ApiKeyTrendResponse> {
const { data } = await apiClient.get<ApiKeyTrendResponse>('/admin/dashboard/api-keys-trend', {
params
})
return data
}
export interface UserTrendParams extends TrendParams {
limit?: number
}
export interface UserTrendResponse {
trend: UserUsageTrendPoint[]
start_date: string
end_date: string
granularity: string
}
export interface UserSpendingRankingParams
extends Pick<TrendParams, 'start_date' | 'end_date'> {
limit?: number
}
/**
* Get user usage trend data
* @param params - Query parameters for filtering
* @returns User usage trend data
*/
export async function getUserUsageTrend(params?: UserTrendParams): Promise<UserTrendResponse> {
const { data } = await apiClient.get<UserTrendResponse>('/admin/dashboard/users-trend', {
params
})
return data
}
/**
* Get user spending ranking data
* @param params - Query parameters for filtering
* @returns User spending ranking data
*/
export async function getUserSpendingRanking(
params?: UserSpendingRankingParams
): Promise<UserSpendingRankingResponse> {
const { data } = await apiClient.get<UserSpendingRankingResponse>('/admin/dashboard/users-ranking', {
params
})
return data
}
export interface BatchUserUsageStats {
user_id: number
today_actual_cost: number
total_actual_cost: number
}
export interface BatchUsersUsageResponse {
stats: Record<string, BatchUserUsageStats>
}
/**
* Get batch usage stats for multiple users
* @param userIds - Array of user IDs
* @returns Usage stats map keyed by user ID
*/
export async function getBatchUsersUsage(userIds: number[]): Promise<BatchUsersUsageResponse> {
const { data } = await apiClient.post<BatchUsersUsageResponse>('/admin/dashboard/users-usage', {
user_ids: userIds
})
return data
}
export interface BatchApiKeyUsageStats {
api_key_id: number
today_actual_cost: number
total_actual_cost: number
}
export interface BatchApiKeysUsageResponse {
stats: Record<string, BatchApiKeyUsageStats>
}
/**
* Get batch usage stats for multiple API keys
* @param apiKeyIds - Array of API key IDs
* @returns Usage stats map keyed by API key ID
*/
export async function getBatchApiKeysUsage(
apiKeyIds: number[]
): Promise<BatchApiKeysUsageResponse> {
const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
'/admin/dashboard/api-keys-usage',
{
api_key_ids: apiKeyIds
}
)
return data
}
export const dashboardAPI = {
getStats,
getRealtimeMetrics,
getUsageTrend,
getModelStats,
getGroupStats,
getSnapshotV2,
getApiKeyUsageTrend,
getUserUsageTrend,
getUserSpendingRanking,
getBatchUsersUsage,
getBatchApiKeysUsage
}
export default dashboardAPI

View File

@@ -0,0 +1,332 @@
import { apiClient } from '../client'
export type BackupType = 'postgres' | 'redis' | 'full'
export type BackupJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'partial_succeeded'
export interface BackupAgentInfo {
status: string
version: string
uptime_seconds: number
}
export interface BackupAgentHealth {
enabled: boolean
reason: string
socket_path: string
agent?: BackupAgentInfo
}
export interface DataManagementPostgresConfig {
host: string
port: number
user: string
password?: string
password_configured?: boolean
database: string
ssl_mode: string
container_name: string
}
export interface DataManagementRedisConfig {
addr: string
username: string
password?: string
password_configured?: boolean
db: number
container_name: string
}
export interface DataManagementS3Config {
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
secret_access_key_configured?: boolean
prefix: string
force_path_style: boolean
use_ssl: boolean
}
export interface DataManagementConfig {
source_mode: 'direct' | 'docker_exec'
backup_root: string
sqlite_path?: string
retention_days: number
keep_last: number
active_postgres_profile_id?: string
active_redis_profile_id?: string
active_s3_profile_id?: string
postgres: DataManagementPostgresConfig
redis: DataManagementRedisConfig
s3: DataManagementS3Config
}
export type SourceType = 'postgres' | 'redis'
export interface DataManagementSourceConfig {
host: string
port: number
user: string
password?: string
database: string
ssl_mode: string
addr: string
username: string
db: number
container_name: string
}
export interface DataManagementSourceProfile {
source_type: SourceType
profile_id: string
name: string
is_active: boolean
password_configured?: boolean
config: DataManagementSourceConfig
created_at?: string
updated_at?: string
}
export interface TestS3Request {
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key: string
prefix?: string
force_path_style?: boolean
use_ssl?: boolean
}
export interface TestS3Response {
ok: boolean
message: string
}
export interface CreateBackupJobRequest {
backup_type: BackupType
upload_to_s3?: boolean
s3_profile_id?: string
postgres_profile_id?: string
redis_profile_id?: string
idempotency_key?: string
}
export interface CreateBackupJobResponse {
job_id: string
status: BackupJobStatus
}
export interface BackupArtifactInfo {
local_path: string
size_bytes: number
sha256: string
}
export interface BackupS3Info {
bucket: string
key: string
etag: string
}
export interface BackupJob {
job_id: string
backup_type: BackupType
status: BackupJobStatus
triggered_by: string
s3_profile_id?: string
postgres_profile_id?: string
redis_profile_id?: string
started_at?: string
finished_at?: string
error_message?: string
artifact?: BackupArtifactInfo
s3?: BackupS3Info
}
export interface ListSourceProfilesResponse {
items: DataManagementSourceProfile[]
}
export interface CreateSourceProfileRequest {
profile_id: string
name: string
config: DataManagementSourceConfig
set_active?: boolean
}
export interface UpdateSourceProfileRequest {
name: string
config: DataManagementSourceConfig
}
export interface DataManagementS3Profile {
profile_id: string
name: string
is_active: boolean
s3: DataManagementS3Config
secret_access_key_configured?: boolean
created_at?: string
updated_at?: string
}
export interface ListS3ProfilesResponse {
items: DataManagementS3Profile[]
}
export interface CreateS3ProfileRequest {
profile_id: string
name: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix?: string
force_path_style?: boolean
use_ssl?: boolean
set_active?: boolean
}
export interface UpdateS3ProfileRequest {
name: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix?: string
force_path_style?: boolean
use_ssl?: boolean
}
export interface ListBackupJobsRequest {
page_size?: number
page_token?: string
status?: BackupJobStatus
backup_type?: BackupType
}
export interface ListBackupJobsResponse {
items: BackupJob[]
next_page_token?: string
}
export async function getAgentHealth(): Promise<BackupAgentHealth> {
const { data } = await apiClient.get<BackupAgentHealth>('/admin/data-management/agent/health')
return data
}
export async function getConfig(): Promise<DataManagementConfig> {
const { data } = await apiClient.get<DataManagementConfig>('/admin/data-management/config')
return data
}
export async function updateConfig(request: DataManagementConfig): Promise<DataManagementConfig> {
const { data } = await apiClient.put<DataManagementConfig>('/admin/data-management/config', request)
return data
}
export async function testS3(request: TestS3Request): Promise<TestS3Response> {
const { data } = await apiClient.post<TestS3Response>('/admin/data-management/s3/test', request)
return data
}
export async function listSourceProfiles(sourceType: SourceType): Promise<ListSourceProfilesResponse> {
const { data } = await apiClient.get<ListSourceProfilesResponse>(`/admin/data-management/sources/${sourceType}/profiles`)
return data
}
export async function createSourceProfile(sourceType: SourceType, request: CreateSourceProfileRequest): Promise<DataManagementSourceProfile> {
const { data } = await apiClient.post<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles`, request)
return data
}
export async function updateSourceProfile(sourceType: SourceType, profileID: string, request: UpdateSourceProfileRequest): Promise<DataManagementSourceProfile> {
const { data } = await apiClient.put<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles/${profileID}`, request)
return data
}
export async function deleteSourceProfile(sourceType: SourceType, profileID: string): Promise<void> {
await apiClient.delete(`/admin/data-management/sources/${sourceType}/profiles/${profileID}`)
}
export async function setActiveSourceProfile(sourceType: SourceType, profileID: string): Promise<DataManagementSourceProfile> {
const { data } = await apiClient.post<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles/${profileID}/activate`)
return data
}
export async function listS3Profiles(): Promise<ListS3ProfilesResponse> {
const { data } = await apiClient.get<ListS3ProfilesResponse>('/admin/data-management/s3/profiles')
return data
}
export async function createS3Profile(request: CreateS3ProfileRequest): Promise<DataManagementS3Profile> {
const { data } = await apiClient.post<DataManagementS3Profile>('/admin/data-management/s3/profiles', request)
return data
}
export async function updateS3Profile(profileID: string, request: UpdateS3ProfileRequest): Promise<DataManagementS3Profile> {
const { data } = await apiClient.put<DataManagementS3Profile>(`/admin/data-management/s3/profiles/${profileID}`, request)
return data
}
export async function deleteS3Profile(profileID: string): Promise<void> {
await apiClient.delete(`/admin/data-management/s3/profiles/${profileID}`)
}
export async function setActiveS3Profile(profileID: string): Promise<DataManagementS3Profile> {
const { data } = await apiClient.post<DataManagementS3Profile>(`/admin/data-management/s3/profiles/${profileID}/activate`)
return data
}
export async function createBackupJob(request: CreateBackupJobRequest): Promise<CreateBackupJobResponse> {
const headers = request.idempotency_key
? { 'X-Idempotency-Key': request.idempotency_key }
: undefined
const { data } = await apiClient.post<CreateBackupJobResponse>(
'/admin/data-management/backups',
request,
{ headers }
)
return data
}
export async function listBackupJobs(request?: ListBackupJobsRequest): Promise<ListBackupJobsResponse> {
const { data } = await apiClient.get<ListBackupJobsResponse>('/admin/data-management/backups', {
params: request
})
return data
}
export async function getBackupJob(jobID: string): Promise<BackupJob> {
const { data } = await apiClient.get<BackupJob>(`/admin/data-management/backups/${jobID}`)
return data
}
export const dataManagementAPI = {
getAgentHealth,
getConfig,
updateConfig,
listSourceProfiles,
createSourceProfile,
updateSourceProfile,
deleteSourceProfile,
setActiveSourceProfile,
testS3,
listS3Profiles,
createS3Profile,
updateS3Profile,
deleteS3Profile,
setActiveS3Profile,
createBackupJob,
listBackupJobs,
getBackupJob
}
export default dataManagementAPI

View File

@@ -0,0 +1,137 @@
/**
* Admin Error Passthrough Rules API endpoints
* Handles error passthrough rule management for administrators
*/
import { apiClient } from '../client'
/**
* Error passthrough rule interface
*/
export interface ErrorPassthroughRule {
id: number
name: string
enabled: boolean
priority: number
error_codes: number[]
keywords: string[]
match_mode: 'any' | 'all'
platforms: string[]
passthrough_code: boolean
response_code: number | null
passthrough_body: boolean
custom_message: string | null
skip_monitoring: boolean
description: string | null
created_at: string
updated_at: string
}
/**
* Create rule request
*/
export interface CreateRuleRequest {
name: string
enabled?: boolean
priority?: number
error_codes?: number[]
keywords?: string[]
match_mode?: 'any' | 'all'
platforms?: string[]
passthrough_code?: boolean
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
skip_monitoring?: boolean
description?: string | null
}
/**
* Update rule request
*/
export interface UpdateRuleRequest {
name?: string
enabled?: boolean
priority?: number
error_codes?: number[]
keywords?: string[]
match_mode?: 'any' | 'all'
platforms?: string[]
passthrough_code?: boolean
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
skip_monitoring?: boolean
description?: string | null
}
/**
* List all error passthrough rules
* @returns List of all rules sorted by priority
*/
export async function list(): Promise<ErrorPassthroughRule[]> {
const { data } = await apiClient.get<ErrorPassthroughRule[]>('/admin/error-passthrough-rules')
return data
}
/**
* Get rule by ID
* @param id - Rule ID
* @returns Rule details
*/
export async function getById(id: number): Promise<ErrorPassthroughRule> {
const { data } = await apiClient.get<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`)
return data
}
/**
* Create new rule
* @param ruleData - Rule data
* @returns Created rule
*/
export async function create(ruleData: CreateRuleRequest): Promise<ErrorPassthroughRule> {
const { data } = await apiClient.post<ErrorPassthroughRule>('/admin/error-passthrough-rules', ruleData)
return data
}
/**
* Update rule
* @param id - Rule ID
* @param updates - Fields to update
* @returns Updated rule
*/
export async function update(id: number, updates: UpdateRuleRequest): Promise<ErrorPassthroughRule> {
const { data } = await apiClient.put<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`, updates)
return data
}
/**
* Delete rule
* @param id - Rule ID
* @returns Success confirmation
*/
export async function deleteRule(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/error-passthrough-rules/${id}`)
return data
}
/**
* Toggle rule enabled status
* @param id - Rule ID
* @param enabled - New enabled status
* @returns Updated rule
*/
export async function toggleEnabled(id: number, enabled: boolean): Promise<ErrorPassthroughRule> {
return update(id, { enabled })
}
export const errorPassthroughAPI = {
list,
getById,
create,
update,
delete: deleteRule,
toggleEnabled
}
export default errorPassthroughAPI

View File

@@ -0,0 +1,72 @@
/**
* Admin Gemini API endpoints
* Handles Gemini OAuth flows for administrators
*/
import { apiClient } from '../client'
export interface GeminiAuthUrlResponse {
auth_url: string
session_id: string
state: string
}
export interface GeminiOAuthCapabilities {
ai_studio_oauth_enabled: boolean
required_redirect_uris: string[]
}
export interface GeminiAuthUrlRequest {
proxy_id?: number
project_id?: string
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
}
export interface GeminiExchangeCodeRequest {
session_id: string
state: string
code: string
proxy_id?: number
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
}
export type GeminiTokenInfo = {
access_token?: string
refresh_token?: string
token_type?: string
scope?: string
expires_in?: number
expires_at?: number
project_id?: string
oauth_type?: string
tier_id?: string
extra?: Record<string, unknown>
[key: string]: unknown
}
export async function generateAuthUrl(
payload: GeminiAuthUrlRequest
): Promise<GeminiAuthUrlResponse> {
const { data } = await apiClient.post<GeminiAuthUrlResponse>(
'/admin/gemini/oauth/auth-url',
payload
)
return data
}
export async function exchangeCode(payload: GeminiExchangeCodeRequest): Promise<GeminiTokenInfo> {
const { data } = await apiClient.post<GeminiTokenInfo>(
'/admin/gemini/oauth/exchange-code',
payload
)
return data
}
export async function getCapabilities(): Promise<GeminiOAuthCapabilities> {
const { data } = await apiClient.get<GeminiOAuthCapabilities>('/admin/gemini/oauth/capabilities')
return data
}
export default { generateAuthUrl, exchangeCode, getCapabilities }

View File

@@ -0,0 +1,238 @@
/**
* Admin Groups API endpoints
* Handles API key group management for administrators
*/
import { apiClient } from '../client'
import type {
AdminGroup,
GroupPlatform,
CreateGroupRequest,
UpdateGroupRequest,
PaginatedResponse
} from '@/types'
/**
* List all groups with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (platform, status, is_exclusive, search)
* @returns Paginated list of groups
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
platform?: GroupPlatform
status?: 'active' | 'inactive'
is_exclusive?: boolean
search?: string
},
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<AdminGroup>> {
const { data } = await apiClient.get<PaginatedResponse<AdminGroup>>('/admin/groups', {
params: {
page,
page_size: pageSize,
...filters
},
signal: options?.signal
})
return data
}
/**
* Get all active groups (without pagination)
* @param platform - Optional platform filter
* @returns List of all active groups
*/
export async function getAll(platform?: GroupPlatform): Promise<AdminGroup[]> {
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
params: platform ? { platform } : undefined
})
return data
}
/**
* Get active groups by platform
* @param platform - Platform to filter by
* @returns List of groups for the specified platform
*/
export async function getByPlatform(platform: GroupPlatform): Promise<AdminGroup[]> {
return getAll(platform)
}
/**
* Get group by ID
* @param id - Group ID
* @returns Group details
*/
export async function getById(id: number): Promise<AdminGroup> {
const { data } = await apiClient.get<AdminGroup>(`/admin/groups/${id}`)
return data
}
/**
* Create new group
* @param groupData - Group data
* @returns Created group
*/
export async function create(groupData: CreateGroupRequest): Promise<AdminGroup> {
const { data } = await apiClient.post<AdminGroup>('/admin/groups', groupData)
return data
}
/**
* Update group
* @param id - Group ID
* @param updates - Fields to update
* @returns Updated group
*/
export async function update(id: number, updates: UpdateGroupRequest): Promise<AdminGroup> {
const { data } = await apiClient.put<AdminGroup>(`/admin/groups/${id}`, updates)
return data
}
/**
* Delete group
* @param id - Group ID
* @returns Success confirmation
*/
export async function deleteGroup(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`)
return data
}
/**
* Toggle group status
* @param id - Group ID
* @param status - New status
* @returns Updated group
*/
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<AdminGroup> {
return update(id, { status })
}
/**
* Get group statistics
* @param id - Group ID
* @returns Group usage statistics
*/
export async function getStats(id: number): Promise<{
total_api_keys: number
active_api_keys: number
total_requests: number
total_cost: number
}> {
const { data } = await apiClient.get<{
total_api_keys: number
active_api_keys: number
total_requests: number
total_cost: number
}>(`/admin/groups/${id}/stats`)
return data
}
/**
* Get API keys in a group
* @param id - Group ID
* @param page - Page number
* @param pageSize - Items per page
* @returns Paginated list of API keys in the group
*/
export async function getGroupApiKeys(
id: number,
page: number = 1,
pageSize: number = 20
): Promise<PaginatedResponse<any>> {
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/groups/${id}/api-keys`, {
params: { page, page_size: pageSize }
})
return data
}
/**
* Rate multiplier entry for a user in a group
*/
export interface GroupRateMultiplierEntry {
user_id: number
user_name: string
user_email: string
user_notes: string
user_status: string
rate_multiplier: number
}
/**
* Get rate multipliers for users in a group
* @param id - Group ID
* @returns List of user rate multiplier entries
*/
export async function getGroupRateMultipliers(id: number): Promise<GroupRateMultiplierEntry[]> {
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
`/admin/groups/${id}/rate-multipliers`
)
return data
}
/**
* Update group sort orders
* @param updates - Array of { id, sort_order } objects
* @returns Success confirmation
*/
export async function updateSortOrder(
updates: Array<{ id: number; sort_order: number }>
): Promise<{ message: string }> {
const { data } = await apiClient.put<{ message: string }>('/admin/groups/sort-order', {
updates
})
return data
}
/**
* Clear all rate multipliers for a group
* @param id - Group ID
* @returns Success confirmation
*/
export async function clearGroupRateMultipliers(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rate-multipliers`)
return data
}
/**
* Batch set rate multipliers for users in a group
* @param id - Group ID
* @param entries - Array of { user_id, rate_multiplier }
* @returns Success confirmation
*/
export async function batchSetGroupRateMultipliers(
id: number,
entries: Array<{ user_id: number; rate_multiplier: number }>
): Promise<{ message: string }> {
const { data } = await apiClient.put<{ message: string }>(
`/admin/groups/${id}/rate-multipliers`,
{ entries }
)
return data
}
export const groupsAPI = {
list,
getAll,
getByPlatform,
getById,
create,
update,
delete: deleteGroup,
toggleStatus,
getStats,
getGroupApiKeys,
getGroupRateMultipliers,
clearGroupRateMultipliers,
batchSetGroupRateMultipliers,
updateSortOrder
}
export default groupsAPI

View File

@@ -0,0 +1,84 @@
/**
* Admin API barrel export
* Centralized exports for all admin API modules
*/
import dashboardAPI from './dashboard'
import usersAPI from './users'
import groupsAPI from './groups'
import accountsAPI from './accounts'
import proxiesAPI from './proxies'
import redeemAPI from './redeem'
import promoAPI from './promo'
import announcementsAPI from './announcements'
import settingsAPI from './settings'
import systemAPI from './system'
import subscriptionsAPI from './subscriptions'
import usageAPI from './usage'
import geminiAPI from './gemini'
import antigravityAPI from './antigravity'
import userAttributesAPI from './userAttributes'
import opsAPI from './ops'
import errorPassthroughAPI from './errorPassthrough'
import dataManagementAPI from './dataManagement'
import apiKeysAPI from './apiKeys'
import scheduledTestsAPI from './scheduledTests'
import backupAPI from './backup'
/**
* Unified admin API object for convenient access
*/
export const adminAPI = {
dashboard: dashboardAPI,
users: usersAPI,
groups: groupsAPI,
accounts: accountsAPI,
proxies: proxiesAPI,
redeem: redeemAPI,
promo: promoAPI,
announcements: announcementsAPI,
settings: settingsAPI,
system: systemAPI,
subscriptions: subscriptionsAPI,
usage: usageAPI,
gemini: geminiAPI,
antigravity: antigravityAPI,
userAttributes: userAttributesAPI,
ops: opsAPI,
errorPassthrough: errorPassthroughAPI,
dataManagement: dataManagementAPI,
apiKeys: apiKeysAPI,
scheduledTests: scheduledTestsAPI,
backup: backupAPI
}
export {
dashboardAPI,
usersAPI,
groupsAPI,
accountsAPI,
proxiesAPI,
redeemAPI,
promoAPI,
announcementsAPI,
settingsAPI,
systemAPI,
subscriptionsAPI,
usageAPI,
geminiAPI,
antigravityAPI,
userAttributesAPI,
opsAPI,
errorPassthroughAPI,
dataManagementAPI,
apiKeysAPI,
scheduledTestsAPI,
backupAPI
}
export default adminAPI
// Re-export types used by components
export type { BalanceHistoryItem } from './users'
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
/**
* Admin Promo Codes API endpoints
*/
import { apiClient } from '../client'
import type {
PromoCode,
PromoCodeUsage,
CreatePromoCodeRequest,
UpdatePromoCodeRequest,
BasePaginationResponse
} from '@/types'
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: string
search?: string
}
): Promise<BasePaginationResponse<PromoCode>> {
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
params: { page, page_size: pageSize, ...filters }
})
return data
}
export async function getById(id: number): Promise<PromoCode> {
const { data } = await apiClient.get<PromoCode>(`/admin/promo-codes/${id}`)
return data
}
export async function create(request: CreatePromoCodeRequest): Promise<PromoCode> {
const { data } = await apiClient.post<PromoCode>('/admin/promo-codes', request)
return data
}
export async function update(id: number, request: UpdatePromoCodeRequest): Promise<PromoCode> {
const { data } = await apiClient.put<PromoCode>(`/admin/promo-codes/${id}`, request)
return data
}
export async function deleteCode(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/promo-codes/${id}`)
return data
}
export async function getUsages(
id: number,
page: number = 1,
pageSize: number = 20
): Promise<BasePaginationResponse<PromoCodeUsage>> {
const { data } = await apiClient.get<BasePaginationResponse<PromoCodeUsage>>(
`/admin/promo-codes/${id}/usages`,
{ params: { page, page_size: pageSize } }
)
return data
}
const promoAPI = {
list,
getById,
create,
update,
delete: deleteCode,
getUsages
}
export default promoAPI

View File

@@ -0,0 +1,271 @@
/**
* Admin Proxies API endpoints
* Handles proxy server management for administrators
*/
import { apiClient } from '../client'
import type {
Proxy,
ProxyAccountSummary,
ProxyQualityCheckResult,
CreateProxyRequest,
UpdateProxyRequest,
PaginatedResponse,
AdminDataPayload,
AdminDataImportResult
} from '@/types'
/**
* List all proxies with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters
* @returns Paginated list of proxies
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
protocol?: string
status?: 'active' | 'inactive'
search?: string
},
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<Proxy>> {
const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', {
params: {
page,
page_size: pageSize,
...filters
},
signal: options?.signal
})
return data
}
/**
* Get all active proxies (without pagination)
* @returns List of all active proxies
*/
export async function getAll(): Promise<Proxy[]> {
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all')
return data
}
/**
* Get all active proxies with account count (sorted by creation time desc)
* @returns List of all active proxies with account count
*/
export async function getAllWithCount(): Promise<Proxy[]> {
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all', {
params: { with_count: 'true' }
})
return data
}
/**
* Get proxy by ID
* @param id - Proxy ID
* @returns Proxy details
*/
export async function getById(id: number): Promise<Proxy> {
const { data } = await apiClient.get<Proxy>(`/admin/proxies/${id}`)
return data
}
/**
* Create new proxy
* @param proxyData - Proxy data
* @returns Created proxy
*/
export async function create(proxyData: CreateProxyRequest): Promise<Proxy> {
const { data } = await apiClient.post<Proxy>('/admin/proxies', proxyData)
return data
}
/**
* Update proxy
* @param id - Proxy ID
* @param updates - Fields to update
* @returns Updated proxy
*/
export async function update(id: number, updates: UpdateProxyRequest): Promise<Proxy> {
const { data } = await apiClient.put<Proxy>(`/admin/proxies/${id}`, updates)
return data
}
/**
* Delete proxy
* @param id - Proxy ID
* @returns Success confirmation
*/
export async function deleteProxy(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/proxies/${id}`)
return data
}
/**
* Toggle proxy status
* @param id - Proxy ID
* @param status - New status
* @returns Updated proxy
*/
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Proxy> {
return update(id, { status })
}
/**
* Test proxy connectivity
* @param id - Proxy ID
* @returns Test result with IP info
*/
export async function testProxy(id: number): Promise<{
success: boolean
message: string
latency_ms?: number
ip_address?: string
city?: string
region?: string
country?: string
country_code?: string
}> {
const { data } = await apiClient.post<{
success: boolean
message: string
latency_ms?: number
ip_address?: string
city?: string
region?: string
country?: string
country_code?: string
}>(`/admin/proxies/${id}/test`)
return data
}
/**
* Check proxy quality across common AI targets
* @param id - Proxy ID
* @returns Quality check result
*/
export async function checkProxyQuality(id: number): Promise<ProxyQualityCheckResult> {
const { data } = await apiClient.post<ProxyQualityCheckResult>(`/admin/proxies/${id}/quality-check`)
return data
}
/**
* Get proxy usage statistics
* @param id - Proxy ID
* @returns Proxy usage statistics
*/
export async function getStats(id: number): Promise<{
total_accounts: number
active_accounts: number
total_requests: number
success_rate: number
average_latency: number
}> {
const { data } = await apiClient.get<{
total_accounts: number
active_accounts: number
total_requests: number
success_rate: number
average_latency: number
}>(`/admin/proxies/${id}/stats`)
return data
}
/**
* Get accounts using a proxy
* @param id - Proxy ID
* @returns List of accounts using the proxy
*/
export async function getProxyAccounts(id: number): Promise<ProxyAccountSummary[]> {
const { data } = await apiClient.get<ProxyAccountSummary[]>(`/admin/proxies/${id}/accounts`)
return data
}
/**
* Batch create proxies
* @param proxies - Array of proxy data to create
* @returns Creation result with count of created and skipped
*/
export async function batchCreate(
proxies: Array<{
protocol: string
host: string
port: number
username?: string
password?: string
}>
): Promise<{
created: number
skipped: number
}> {
const { data } = await apiClient.post<{
created: number
skipped: number
}>('/admin/proxies/batch', { proxies })
return data
}
export async function batchDelete(ids: number[]): Promise<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}> {
const { data } = await apiClient.post<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}>('/admin/proxies/batch-delete', { ids })
return data
}
export async function exportData(options?: {
ids?: number[]
filters?: {
protocol?: string
status?: 'active' | 'inactive'
search?: string
}
}): Promise<AdminDataPayload> {
const params: Record<string, string> = {}
if (options?.ids && options.ids.length > 0) {
params.ids = options.ids.join(',')
} else if (options?.filters) {
const { protocol, status, search } = options.filters
if (protocol) params.protocol = protocol
if (status) params.status = status
if (search) params.search = search
}
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
return data
}
export async function importData(payload: {
data: AdminDataPayload
}): Promise<AdminDataImportResult> {
const { data } = await apiClient.post<AdminDataImportResult>('/admin/proxies/data', payload)
return data
}
export const proxiesAPI = {
list,
getAll,
getAllWithCount,
getById,
create,
update,
delete: deleteProxy,
toggleStatus,
testProxy,
checkProxyQuality,
getStats,
getProxyAccounts,
batchCreate,
batchDelete,
exportData,
importData
}
export default proxiesAPI

View File

@@ -0,0 +1,174 @@
/**
* Admin Redeem Codes API endpoints
* Handles redeem code generation and management for administrators
*/
import { apiClient } from '../client'
import type {
RedeemCode,
GenerateRedeemCodesRequest,
RedeemCodeType,
PaginatedResponse
} from '@/types'
/**
* List all redeem codes with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters
* @returns Paginated list of redeem codes
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
type?: RedeemCodeType
status?: 'active' | 'used' | 'expired' | 'unused'
search?: string
},
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<RedeemCode>> {
const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', {
params: {
page,
page_size: pageSize,
...filters
},
signal: options?.signal
})
return data
}
/**
* Get redeem code by ID
* @param id - Redeem code ID
* @returns Redeem code details
*/
export async function getById(id: number): Promise<RedeemCode> {
const { data } = await apiClient.get<RedeemCode>(`/admin/redeem-codes/${id}`)
return data
}
/**
* Generate new redeem codes
* @param count - Number of codes to generate
* @param type - Type of redeem code
* @param value - Value of the code
* @param groupId - Group ID (required for subscription type)
* @param validityDays - Validity days (for subscription type)
* @returns Array of generated redeem codes
*/
export async function generate(
count: number,
type: RedeemCodeType,
value: number,
groupId?: number | null,
validityDays?: number
): Promise<RedeemCode[]> {
const payload: GenerateRedeemCodesRequest = {
count,
type,
value
}
// 订阅类型专用字段
if (type === 'subscription') {
payload.group_id = groupId
if (validityDays && validityDays > 0) {
payload.validity_days = validityDays
}
}
const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload)
return data
}
/**
* Delete redeem code
* @param id - Redeem code ID
* @returns Success confirmation
*/
export async function deleteCode(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/redeem-codes/${id}`)
return data
}
/**
* Batch delete redeem codes
* @param ids - Array of redeem code IDs
* @returns Success confirmation
*/
export async function batchDelete(ids: number[]): Promise<{
deleted: number
message: string
}> {
const { data } = await apiClient.post<{
deleted: number
message: string
}>('/admin/redeem-codes/batch-delete', { ids })
return data
}
/**
* Expire redeem code
* @param id - Redeem code ID
* @returns Updated redeem code
*/
export async function expire(id: number): Promise<RedeemCode> {
const { data } = await apiClient.post<RedeemCode>(`/admin/redeem-codes/${id}/expire`)
return data
}
/**
* Get redeem code statistics
* @returns Statistics about redeem codes
*/
export async function getStats(): Promise<{
total_codes: number
active_codes: number
used_codes: number
expired_codes: number
total_value_distributed: number
by_type: Record<RedeemCodeType, number>
}> {
const { data } = await apiClient.get<{
total_codes: number
active_codes: number
used_codes: number
expired_codes: number
total_value_distributed: number
by_type: Record<RedeemCodeType, number>
}>('/admin/redeem-codes/stats')
return data
}
/**
* Export redeem codes to CSV
* @param filters - Optional filters
* @returns CSV data as blob
*/
export async function exportCodes(filters?: {
type?: RedeemCodeType
status?: 'active' | 'used' | 'expired'
}): Promise<Blob> {
const response = await apiClient.get('/admin/redeem-codes/export', {
params: filters,
responseType: 'blob'
})
return response.data
}
export const redeemAPI = {
list,
getById,
generate,
delete: deleteCode,
batchDelete,
expire,
getStats,
exportCodes
}
export default redeemAPI

View File

@@ -0,0 +1,85 @@
/**
* Admin Scheduled Tests API endpoints
* Handles scheduled test plan management for account connectivity monitoring
*/
import { apiClient } from '../client'
import type {
ScheduledTestPlan,
ScheduledTestResult,
CreateScheduledTestPlanRequest,
UpdateScheduledTestPlanRequest
} from '@/types'
/**
* List all scheduled test plans for an account
* @param accountId - Account ID
* @returns List of scheduled test plans
*/
export async function listByAccount(accountId: number): Promise<ScheduledTestPlan[]> {
const { data } = await apiClient.get<ScheduledTestPlan[]>(
`/admin/accounts/${accountId}/scheduled-test-plans`
)
return data ?? []
}
/**
* Create a new scheduled test plan
* @param req - Plan creation request
* @returns Created plan
*/
export async function create(req: CreateScheduledTestPlanRequest): Promise<ScheduledTestPlan> {
const { data } = await apiClient.post<ScheduledTestPlan>(
'/admin/scheduled-test-plans',
req
)
return data
}
/**
* Update an existing scheduled test plan
* @param id - Plan ID
* @param req - Fields to update
* @returns Updated plan
*/
export async function update(id: number, req: UpdateScheduledTestPlanRequest): Promise<ScheduledTestPlan> {
const { data } = await apiClient.put<ScheduledTestPlan>(
`/admin/scheduled-test-plans/${id}`,
req
)
return data
}
/**
* Delete a scheduled test plan
* @param id - Plan ID
*/
export async function deletePlan(id: number): Promise<void> {
await apiClient.delete(`/admin/scheduled-test-plans/${id}`)
}
/**
* List test results for a plan
* @param planId - Plan ID
* @param limit - Optional max number of results to return
* @returns List of test results
*/
export async function listResults(planId: number, limit?: number): Promise<ScheduledTestResult[]> {
const { data } = await apiClient.get<ScheduledTestResult[]>(
`/admin/scheduled-test-plans/${planId}/results`,
{
params: limit ? { limit } : undefined
}
)
return data ?? []
}
export const scheduledTestsAPI = {
listByAccount,
create,
update,
delete: deletePlan,
listResults
}
export default scheduledTestsAPI

View File

@@ -0,0 +1,518 @@
/**
* Admin Settings API endpoints
* Handles system settings management for administrators
*/
import { apiClient } from '../client'
import type { CustomMenuItem } from '@/types'
export interface DefaultSubscriptionSetting {
group_id: number
validity_days: number
}
/**
* System settings interface
*/
export interface SystemSettings {
// Registration settings
registration_enabled: boolean
email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean
password_reset_enabled: boolean
frontend_url: string
invitation_code_enabled: boolean
totp_enabled: boolean // TOTP 双因素认证
totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置
// Default settings
default_balance: number
default_concurrency: number
default_subscriptions: DefaultSubscriptionSetting[]
// OEM settings
site_name: string
site_logo: string
site_subtitle: string
api_base_url: string
contact_info: string
doc_url: string
home_content: string
hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
sora_client_enabled: boolean
backend_mode_enabled: boolean
custom_menu_items: CustomMenuItem[]
// SMTP settings
smtp_host: string
smtp_port: number
smtp_username: string
smtp_password_configured: boolean
smtp_from_email: string
smtp_from_name: string
smtp_use_tls: boolean
// Cloudflare Turnstile settings
turnstile_enabled: boolean
turnstile_site_key: string
turnstile_secret_key_configured: boolean
// LinuxDo Connect OAuth settings
linuxdo_connect_enabled: boolean
linuxdo_connect_client_id: string
linuxdo_connect_client_secret_configured: boolean
linuxdo_connect_redirect_url: string
// Model fallback configuration
enable_model_fallback: boolean
fallback_model_anthropic: string
fallback_model_openai: string
fallback_model_gemini: string
fallback_model_antigravity: string
// Identity patch configuration (Claude -> Gemini)
enable_identity_patch: boolean
identity_patch_prompt: string
// Ops Monitoring (vNext)
ops_monitoring_enabled: boolean
ops_realtime_monitoring_enabled: boolean
ops_query_mode_default: 'auto' | 'raw' | 'preagg' | string
ops_metrics_interval_seconds: number
// Claude Code version check
min_claude_code_version: string
// 分组隔离
allow_ungrouped_key_scheduling: boolean
}
export interface UpdateSettingsRequest {
registration_enabled?: boolean
email_verify_enabled?: boolean
registration_email_suffix_whitelist?: string[]
promo_code_enabled?: boolean
password_reset_enabled?: boolean
frontend_url?: string
invitation_code_enabled?: boolean
totp_enabled?: boolean // TOTP 双因素认证
default_balance?: number
default_concurrency?: number
default_subscriptions?: DefaultSubscriptionSetting[]
site_name?: string
site_logo?: string
site_subtitle?: string
api_base_url?: string
contact_info?: string
doc_url?: string
home_content?: string
hide_ccs_import_button?: boolean
purchase_subscription_enabled?: boolean
purchase_subscription_url?: string
sora_client_enabled?: boolean
backend_mode_enabled?: boolean
custom_menu_items?: CustomMenuItem[]
smtp_host?: string
smtp_port?: number
smtp_username?: string
smtp_password?: string
smtp_from_email?: string
smtp_from_name?: string
smtp_use_tls?: boolean
turnstile_enabled?: boolean
turnstile_site_key?: string
turnstile_secret_key?: string
linuxdo_connect_enabled?: boolean
linuxdo_connect_client_id?: string
linuxdo_connect_client_secret?: string
linuxdo_connect_redirect_url?: string
enable_model_fallback?: boolean
fallback_model_anthropic?: string
fallback_model_openai?: string
fallback_model_gemini?: string
fallback_model_antigravity?: string
enable_identity_patch?: boolean
identity_patch_prompt?: string
ops_monitoring_enabled?: boolean
ops_realtime_monitoring_enabled?: boolean
ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string
ops_metrics_interval_seconds?: number
min_claude_code_version?: string
allow_ungrouped_key_scheduling?: boolean
}
/**
* Get all system settings
* @returns System settings
*/
export async function getSettings(): Promise<SystemSettings> {
const { data } = await apiClient.get<SystemSettings>('/admin/settings')
return data
}
/**
* Update system settings
* @param settings - Partial settings to update
* @returns Updated settings
*/
export async function updateSettings(settings: UpdateSettingsRequest): Promise<SystemSettings> {
const { data } = await apiClient.put<SystemSettings>('/admin/settings', settings)
return data
}
/**
* Test SMTP connection request
*/
export interface TestSmtpRequest {
smtp_host: string
smtp_port: number
smtp_username: string
smtp_password: string
smtp_use_tls: boolean
}
/**
* Test SMTP connection with provided config
* @param config - SMTP configuration to test
* @returns Test result message
*/
export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config)
return data
}
/**
* Send test email request
*/
export interface SendTestEmailRequest {
email: string
smtp_host: string
smtp_port: number
smtp_username: string
smtp_password: string
smtp_from_email: string
smtp_from_name: string
smtp_use_tls: boolean
}
/**
* Send test email with provided SMTP config
* @param request - Email address and SMTP config
* @returns Test result message
*/
export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>(
'/admin/settings/send-test-email',
request
)
return data
}
/**
* Admin API Key status response
*/
export interface AdminApiKeyStatus {
exists: boolean
masked_key: string
}
/**
* Get admin API key status
* @returns Status indicating if key exists and masked version
*/
export async function getAdminApiKey(): Promise<AdminApiKeyStatus> {
const { data } = await apiClient.get<AdminApiKeyStatus>('/admin/settings/admin-api-key')
return data
}
/**
* Regenerate admin API key
* @returns The new full API key (only shown once)
*/
export async function regenerateAdminApiKey(): Promise<{ key: string }> {
const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate')
return data
}
/**
* Delete admin API key
* @returns Success message
*/
export async function deleteAdminApiKey(): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key')
return data
}
/**
* Stream timeout settings interface
*/
export interface StreamTimeoutSettings {
enabled: boolean
action: 'temp_unsched' | 'error' | 'none'
temp_unsched_minutes: number
threshold_count: number
threshold_window_minutes: number
}
/**
* Get stream timeout settings
* @returns Stream timeout settings
*/
export async function getStreamTimeoutSettings(): Promise<StreamTimeoutSettings> {
const { data } = await apiClient.get<StreamTimeoutSettings>('/admin/settings/stream-timeout')
return data
}
/**
* Update stream timeout settings
* @param settings - Stream timeout settings to update
* @returns Updated settings
*/
export async function updateStreamTimeoutSettings(
settings: StreamTimeoutSettings
): Promise<StreamTimeoutSettings> {
const { data } = await apiClient.put<StreamTimeoutSettings>(
'/admin/settings/stream-timeout',
settings
)
return data
}
// ==================== Rectifier Settings ====================
/**
* Rectifier settings interface
*/
export interface RectifierSettings {
enabled: boolean
thinking_signature_enabled: boolean
thinking_budget_enabled: boolean
}
/**
* Get rectifier settings
* @returns Rectifier settings
*/
export async function getRectifierSettings(): Promise<RectifierSettings> {
const { data } = await apiClient.get<RectifierSettings>('/admin/settings/rectifier')
return data
}
/**
* Update rectifier settings
* @param settings - Rectifier settings to update
* @returns Updated settings
*/
export async function updateRectifierSettings(
settings: RectifierSettings
): Promise<RectifierSettings> {
const { data } = await apiClient.put<RectifierSettings>(
'/admin/settings/rectifier',
settings
)
return data
}
// ==================== Beta Policy Settings ====================
/**
* Beta policy rule interface
*/
export interface BetaPolicyRule {
beta_token: string
action: 'pass' | 'filter' | 'block'
scope: 'all' | 'oauth' | 'apikey' | 'bedrock'
error_message?: string
}
/**
* Beta policy settings interface
*/
export interface BetaPolicySettings {
rules: BetaPolicyRule[]
}
/**
* Get beta policy settings
* @returns Beta policy settings
*/
export async function getBetaPolicySettings(): Promise<BetaPolicySettings> {
const { data } = await apiClient.get<BetaPolicySettings>('/admin/settings/beta-policy')
return data
}
/**
* Update beta policy settings
* @param settings - Beta policy settings to update
* @returns Updated settings
*/
export async function updateBetaPolicySettings(
settings: BetaPolicySettings
): Promise<BetaPolicySettings> {
const { data } = await apiClient.put<BetaPolicySettings>(
'/admin/settings/beta-policy',
settings
)
return data
}
// ==================== Sora S3 Settings ====================
export interface SoraS3Settings {
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key_configured: boolean
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface SoraS3Profile {
profile_id: string
name: string
is_active: boolean
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key_configured: boolean
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
updated_at: string
}
export interface ListSoraS3ProfilesResponse {
active_profile_id: string
items: SoraS3Profile[]
}
export interface UpdateSoraS3SettingsRequest {
profile_id?: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface CreateSoraS3ProfileRequest {
profile_id: string
name: string
set_active?: boolean
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface UpdateSoraS3ProfileRequest {
name: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface TestSoraS3ConnectionRequest {
profile_id?: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes?: number
}
export async function getSoraS3Settings(): Promise<SoraS3Settings> {
const { data } = await apiClient.get<SoraS3Settings>('/admin/settings/sora-s3')
return data
}
export async function updateSoraS3Settings(settings: UpdateSoraS3SettingsRequest): Promise<SoraS3Settings> {
const { data } = await apiClient.put<SoraS3Settings>('/admin/settings/sora-s3', settings)
return data
}
export async function testSoraS3Connection(
settings: TestSoraS3ConnectionRequest
): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>('/admin/settings/sora-s3/test', settings)
return data
}
export async function listSoraS3Profiles(): Promise<ListSoraS3ProfilesResponse> {
const { data } = await apiClient.get<ListSoraS3ProfilesResponse>('/admin/settings/sora-s3/profiles')
return data
}
export async function createSoraS3Profile(request: CreateSoraS3ProfileRequest): Promise<SoraS3Profile> {
const { data } = await apiClient.post<SoraS3Profile>('/admin/settings/sora-s3/profiles', request)
return data
}
export async function updateSoraS3Profile(profileID: string, request: UpdateSoraS3ProfileRequest): Promise<SoraS3Profile> {
const { data } = await apiClient.put<SoraS3Profile>(`/admin/settings/sora-s3/profiles/${profileID}`, request)
return data
}
export async function deleteSoraS3Profile(profileID: string): Promise<void> {
await apiClient.delete(`/admin/settings/sora-s3/profiles/${profileID}`)
}
export async function setActiveSoraS3Profile(profileID: string): Promise<SoraS3Profile> {
const { data } = await apiClient.post<SoraS3Profile>(`/admin/settings/sora-s3/profiles/${profileID}/activate`)
return data
}
export const settingsAPI = {
getSettings,
updateSettings,
testSmtpConnection,
sendTestEmail,
getAdminApiKey,
regenerateAdminApiKey,
deleteAdminApiKey,
getStreamTimeoutSettings,
updateStreamTimeoutSettings,
getRectifierSettings,
updateRectifierSettings,
getBetaPolicySettings,
updateBetaPolicySettings,
getSoraS3Settings,
updateSoraS3Settings,
testSoraS3Connection,
listSoraS3Profiles,
createSoraS3Profile,
updateSoraS3Profile,
deleteSoraS3Profile,
setActiveSoraS3Profile
}
export default settingsAPI

View File

@@ -0,0 +1,195 @@
/**
* Admin Subscriptions API endpoints
* Handles user subscription management for administrators
*/
import { apiClient } from '../client'
import type {
UserSubscription,
SubscriptionProgress,
AssignSubscriptionRequest,
BulkAssignSubscriptionRequest,
ExtendSubscriptionRequest,
PaginatedResponse
} from '@/types'
/**
* List all subscriptions with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (status, user_id, group_id, sort_by, sort_order)
* @returns Paginated list of subscriptions
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: 'active' | 'expired' | 'revoked'
user_id?: number
group_id?: number
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<UserSubscription>> {
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
'/admin/subscriptions',
{
params: {
page,
page_size: pageSize,
...filters
},
signal: options?.signal
}
)
return data
}
/**
* Get subscription by ID
* @param id - Subscription ID
* @returns Subscription details
*/
export async function getById(id: number): Promise<UserSubscription> {
const { data } = await apiClient.get<UserSubscription>(`/admin/subscriptions/${id}`)
return data
}
/**
* Get subscription progress
* @param id - Subscription ID
* @returns Subscription progress with usage stats
*/
export async function getProgress(id: number): Promise<SubscriptionProgress> {
const { data } = await apiClient.get<SubscriptionProgress>(`/admin/subscriptions/${id}/progress`)
return data
}
/**
* Assign subscription to user
* @param request - Assignment request
* @returns Created subscription
*/
export async function assign(request: AssignSubscriptionRequest): Promise<UserSubscription> {
const { data } = await apiClient.post<UserSubscription>('/admin/subscriptions/assign', request)
return data
}
/**
* Bulk assign subscriptions to multiple users
* @param request - Bulk assignment request
* @returns Created subscriptions
*/
export async function bulkAssign(
request: BulkAssignSubscriptionRequest
): Promise<UserSubscription[]> {
const { data } = await apiClient.post<UserSubscription[]>(
'/admin/subscriptions/bulk-assign',
request
)
return data
}
/**
* Extend subscription validity
* @param id - Subscription ID
* @param request - Extension request with days
* @returns Updated subscription
*/
export async function extend(
id: number,
request: ExtendSubscriptionRequest
): Promise<UserSubscription> {
const { data } = await apiClient.post<UserSubscription>(
`/admin/subscriptions/${id}/extend`,
request
)
return data
}
/**
* Revoke subscription
* @param id - Subscription ID
* @returns Success confirmation
*/
export async function revoke(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/subscriptions/${id}`)
return data
}
/**
* Reset daily, weekly, and/or monthly usage quota for a subscription
* @param id - Subscription ID
* @param options - Which windows to reset
* @returns Updated subscription
*/
export async function resetQuota(
id: number,
options: { daily: boolean; weekly: boolean; monthly: boolean }
): Promise<UserSubscription> {
const { data } = await apiClient.post<UserSubscription>(
`/admin/subscriptions/${id}/reset-quota`,
options
)
return data
}
/**
* List subscriptions by group
* @param groupId - Group ID
* @param page - Page number
* @param pageSize - Items per page
* @returns Paginated list of subscriptions in the group
*/
export async function listByGroup(
groupId: number,
page: number = 1,
pageSize: number = 20
): Promise<PaginatedResponse<UserSubscription>> {
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
`/admin/groups/${groupId}/subscriptions`,
{
params: { page, page_size: pageSize }
}
)
return data
}
/**
* List subscriptions by user
* @param userId - User ID
* @param page - Page number
* @param pageSize - Items per page
* @returns Paginated list of user's subscriptions
*/
export async function listByUser(
userId: number,
page: number = 1,
pageSize: number = 20
): Promise<PaginatedResponse<UserSubscription>> {
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
`/admin/users/${userId}/subscriptions`,
{
params: { page, page_size: pageSize }
}
)
return data
}
export const subscriptionsAPI = {
list,
getById,
getProgress,
assign,
bulkAssign,
extend,
revoke,
resetQuota,
listByGroup,
listByUser
}
export default subscriptionsAPI

View File

@@ -0,0 +1,81 @@
/**
* System API endpoints for admin operations
*/
import { apiClient } from '../client'
export interface ReleaseInfo {
name: string
body: string
published_at: string
html_url: string
}
export interface VersionInfo {
current_version: string
latest_version: string
has_update: boolean
release_info?: ReleaseInfo
cached: boolean
warning?: string
build_type: string // "source" for manual builds, "release" for CI builds
}
/**
* Get current version
*/
export async function getVersion(): Promise<{ version: string }> {
const { data } = await apiClient.get<{ version: string }>('/admin/system/version')
return data
}
/**
* Check for updates
* @param force - Force refresh from GitHub API
*/
export async function checkUpdates(force = false): Promise<VersionInfo> {
const { data } = await apiClient.get<VersionInfo>('/admin/system/check-updates', {
params: force ? { force: 'true' } : undefined
})
return data
}
export interface UpdateResult {
message: string
need_restart: boolean
}
/**
* Perform system update
* Downloads and applies the latest version
*/
export async function performUpdate(): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>('/admin/system/update')
return data
}
/**
* Rollback to previous version
*/
export async function rollback(): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>('/admin/system/rollback')
return data
}
/**
* Restart the service
*/
export async function restartService(): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>('/admin/system/restart')
return data
}
export const systemAPI = {
getVersion,
checkUpdates,
performUpdate,
rollback,
restartService
}
export default systemAPI

View File

@@ -0,0 +1,206 @@
/**
* Admin Usage API endpoints
* Handles admin-level usage logs and statistics retrieval
*/
import { apiClient } from '../client'
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse, UsageRequestType } from '@/types'
import type { EndpointStat } from '@/types'
// ==================== Types ====================
export interface AdminUsageStatsResponse {
total_requests: number
total_input_tokens: number
total_output_tokens: number
total_cache_tokens: number
total_tokens: number
total_cost: number
total_actual_cost: number
total_account_cost?: number
average_duration_ms: number
endpoints?: EndpointStat[]
upstream_endpoints?: EndpointStat[]
endpoint_paths?: EndpointStat[]
}
export interface SimpleUser {
id: number
email: string
}
export interface SimpleApiKey {
id: number
name: string
user_id: number
}
export interface UsageCleanupFilters {
start_time: string
end_time: string
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
model?: string | null
request_type?: UsageRequestType | null
stream?: boolean | null
billing_type?: number | null
}
export interface UsageCleanupTask {
id: number
status: string
filters: UsageCleanupFilters
created_by: number
deleted_rows: number
error_message?: string | null
canceled_by?: number | null
canceled_at?: string | null
started_at?: string | null
finished_at?: string | null
created_at: string
updated_at: string
}
export interface CreateUsageCleanupTaskRequest {
start_date: string
end_date: string
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
model?: string | null
request_type?: UsageRequestType | null
stream?: boolean | null
billing_type?: number | null
timezone?: string
}
export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number
exact_total?: boolean
}
// ==================== API Functions ====================
/**
* List all usage logs with optional filters (admin only)
* @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs
*/
export async function list(
params: AdminUsageQueryParams,
options?: { signal?: AbortSignal }
): Promise<PaginatedResponse<AdminUsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<AdminUsageLog>>('/admin/usage', {
params,
signal: options?.signal
})
return data
}
/**
* Get usage statistics with optional filters (admin only)
* @param params - Query parameters for filtering
* @returns Usage statistics
*/
export async function getStats(params: {
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
model?: string
request_type?: UsageRequestType
stream?: boolean
period?: string
start_date?: string
end_date?: string
timezone?: string
}): Promise<AdminUsageStatsResponse> {
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
params
})
return data
}
/**
* Search users by email keyword (admin only)
* @param keyword - Email keyword to search
* @returns List of matching users (max 30)
*/
export async function searchUsers(keyword: string): Promise<SimpleUser[]> {
const { data } = await apiClient.get<SimpleUser[]>('/admin/usage/search-users', {
params: { q: keyword }
})
return data
}
/**
* Search API keys by user ID and/or keyword (admin only)
* @param userId - Optional user ID to filter by
* @param keyword - Optional keyword to search in key name
* @returns List of matching API keys (max 30)
*/
export async function searchApiKeys(userId?: number, keyword?: string): Promise<SimpleApiKey[]> {
const params: Record<string, unknown> = {}
if (userId !== undefined) {
params.user_id = userId
}
if (keyword) {
params.q = keyword
}
const { data } = await apiClient.get<SimpleApiKey[]>('/admin/usage/search-api-keys', {
params
})
return data
}
/**
* List usage cleanup tasks (admin only)
* @param params - Query parameters for pagination
* @returns Paginated list of cleanup tasks
*/
export async function listCleanupTasks(
params: { page?: number; page_size?: number },
options?: { signal?: AbortSignal }
): Promise<PaginatedResponse<UsageCleanupTask>> {
const { data } = await apiClient.get<PaginatedResponse<UsageCleanupTask>>('/admin/usage/cleanup-tasks', {
params,
signal: options?.signal
})
return data
}
/**
* Create a usage cleanup task (admin only)
* @param payload - Cleanup task parameters
* @returns Created cleanup task
*/
export async function createCleanupTask(payload: CreateUsageCleanupTaskRequest): Promise<UsageCleanupTask> {
const { data } = await apiClient.post<UsageCleanupTask>('/admin/usage/cleanup-tasks', payload)
return data
}
/**
* Cancel a usage cleanup task (admin only)
* @param taskId - Task ID to cancel
*/
export async function cancelCleanupTask(taskId: number): Promise<{ id: number; status: string }> {
const { data } = await apiClient.post<{ id: number; status: string }>(
`/admin/usage/cleanup-tasks/${taskId}/cancel`
)
return data
}
export const adminUsageAPI = {
list,
getStats,
searchUsers,
searchApiKeys,
listCleanupTasks,
createCleanupTask,
cancelCleanupTask
}
export default adminUsageAPI

View File

@@ -0,0 +1,131 @@
/**
* Admin User Attributes API endpoints
* Handles user custom attribute definitions and values
*/
import { apiClient } from '../client'
import type {
UserAttributeDefinition,
UserAttributeValue,
CreateUserAttributeRequest,
UpdateUserAttributeRequest,
UserAttributeValuesMap
} from '@/types'
/**
* Get all attribute definitions
*/
export async function listDefinitions(): Promise<UserAttributeDefinition[]> {
const { data } = await apiClient.get<UserAttributeDefinition[]>('/admin/user-attributes')
return data
}
/**
* Get enabled attribute definitions only
*/
export async function listEnabledDefinitions(): Promise<UserAttributeDefinition[]> {
const { data } = await apiClient.get<UserAttributeDefinition[]>('/admin/user-attributes', {
params: { enabled: true }
})
return data
}
/**
* Create a new attribute definition
*/
export async function createDefinition(
request: CreateUserAttributeRequest
): Promise<UserAttributeDefinition> {
const { data } = await apiClient.post<UserAttributeDefinition>('/admin/user-attributes', request)
return data
}
/**
* Update an attribute definition
*/
export async function updateDefinition(
id: number,
request: UpdateUserAttributeRequest
): Promise<UserAttributeDefinition> {
const { data } = await apiClient.put<UserAttributeDefinition>(
`/admin/user-attributes/${id}`,
request
)
return data
}
/**
* Delete an attribute definition
*/
export async function deleteDefinition(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/user-attributes/${id}`)
return data
}
/**
* Reorder attribute definitions
*/
export async function reorderDefinitions(ids: number[]): Promise<{ message: string }> {
const { data } = await apiClient.put<{ message: string }>('/admin/user-attributes/reorder', {
ids
})
return data
}
/**
* Get user's attribute values
*/
export async function getUserAttributeValues(userId: number): Promise<UserAttributeValue[]> {
const { data } = await apiClient.get<UserAttributeValue[]>(
`/admin/users/${userId}/attributes`
)
return data
}
/**
* Update user's attribute values (batch)
*/
export async function updateUserAttributeValues(
userId: number,
values: UserAttributeValuesMap
): Promise<{ message: string }> {
const { data } = await apiClient.put<{ message: string }>(
`/admin/users/${userId}/attributes`,
{ values }
)
return data
}
/**
* Batch response type
*/
export interface BatchUserAttributesResponse {
attributes: Record<number, Record<number, string>>
}
/**
* Get attribute values for multiple users
*/
export async function getBatchUserAttributes(
userIds: number[]
): Promise<BatchUserAttributesResponse> {
const { data } = await apiClient.post<BatchUserAttributesResponse>(
'/admin/user-attributes/batch',
{ user_ids: userIds }
)
return data
}
export const userAttributesAPI = {
listDefinitions,
listEnabledDefinitions,
createDefinition,
updateDefinition,
deleteDefinition,
reorderDefinitions,
getUserAttributeValues,
updateUserAttributeValues,
getBatchUserAttributes
}
export default userAttributesAPI

View File

@@ -0,0 +1,240 @@
/**
* Admin Users API endpoints
* Handles user management for administrators
*/
import { apiClient } from '../client'
import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types'
/**
* List all users with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (status, role, search, attributes)
* @param options - Optional request options (signal)
* @returns Paginated list of users
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: 'active' | 'disabled'
role?: 'admin' | 'user'
search?: string
attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean
},
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<AdminUser>> {
// Build params with attribute filters in attr[id]=value format
const params: Record<string, any> = {
page,
page_size: pageSize,
status: filters?.status,
role: filters?.role,
search: filters?.search,
include_subscriptions: filters?.include_subscriptions
}
// Add attribute filters as attr[id]=value
if (filters?.attributes) {
for (const [attrId, value] of Object.entries(filters.attributes)) {
if (value) {
params[`attr[${attrId}]`] = value
}
}
}
const { data } = await apiClient.get<PaginatedResponse<AdminUser>>('/admin/users', {
params,
signal: options?.signal
})
return data
}
/**
* Get user by ID
* @param id - User ID
* @returns User details
*/
export async function getById(id: number): Promise<AdminUser> {
const { data } = await apiClient.get<AdminUser>(`/admin/users/${id}`)
return data
}
/**
* Create new user
* @param userData - User data (email, password, etc.)
* @returns Created user
*/
export async function create(userData: {
email: string
password: string
balance?: number
concurrency?: number
allowed_groups?: number[] | null
}): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('/admin/users', userData)
return data
}
/**
* Update user
* @param id - User ID
* @param updates - Fields to update
* @returns Updated user
*/
export async function update(id: number, updates: UpdateUserRequest): Promise<AdminUser> {
const { data } = await apiClient.put<AdminUser>(`/admin/users/${id}`, updates)
return data
}
/**
* Delete user
* @param id - User ID
* @returns Success confirmation
*/
export async function deleteUser(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/users/${id}`)
return data
}
/**
* Update user balance
* @param id - User ID
* @param balance - New balance
* @param operation - Operation type ('set', 'add', 'subtract')
* @param notes - Optional notes for the balance adjustment
* @returns Updated user
*/
export async function updateBalance(
id: number,
balance: number,
operation: 'set' | 'add' | 'subtract' = 'set',
notes?: string
): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>(`/admin/users/${id}/balance`, {
balance,
operation,
notes: notes || ''
})
return data
}
/**
* Update user concurrency
* @param id - User ID
* @param concurrency - New concurrency limit
* @returns Updated user
*/
export async function updateConcurrency(id: number, concurrency: number): Promise<AdminUser> {
return update(id, { concurrency })
}
/**
* Toggle user status
* @param id - User ID
* @param status - New status
* @returns Updated user
*/
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<AdminUser> {
return update(id, { status })
}
/**
* Get user's API keys
* @param id - User ID
* @returns List of user's API keys
*/
export async function getUserApiKeys(id: number): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>(`/admin/users/${id}/api-keys`)
return data
}
/**
* Get user's usage statistics
* @param id - User ID
* @param period - Time period
* @returns User usage statistics
*/
export async function getUserUsageStats(
id: number,
period: string = 'month'
): Promise<{
total_requests: number
total_cost: number
total_tokens: number
}> {
const { data } = await apiClient.get<{
total_requests: number
total_cost: number
total_tokens: number
}>(`/admin/users/${id}/usage`, {
params: { period }
})
return data
}
/**
* Balance history item returned from the API
*/
export interface BalanceHistoryItem {
id: number
code: string
type: string
value: number
status: string
used_by: number | null
used_at: string | null
created_at: string
group_id: number | null
validity_days: number
notes: string
user?: { id: number; email: string } | null
group?: { id: number; name: string } | null
}
// Balance history response extends pagination with total_recharged summary
export interface BalanceHistoryResponse extends PaginatedResponse<BalanceHistoryItem> {
total_recharged: number
}
/**
* Get user's balance/concurrency change history
* @param id - User ID
* @param page - Page number
* @param pageSize - Items per page
* @param type - Optional type filter (balance, admin_balance, concurrency, admin_concurrency, subscription)
* @returns Paginated balance history with total_recharged
*/
export async function getUserBalanceHistory(
id: number,
page: number = 1,
pageSize: number = 20,
type?: string
): Promise<BalanceHistoryResponse> {
const params: Record<string, any> = { page, page_size: pageSize }
if (type) params.type = type
const { data } = await apiClient.get<BalanceHistoryResponse>(
`/admin/users/${id}/balance-history`,
{ params }
)
return data
}
export const usersAPI = {
list,
getById,
create,
update,
delete: deleteUser,
updateBalance,
updateConcurrency,
toggleStatus,
getUserApiKeys,
getUserUsageStats,
getUserBalanceHistory
}
export default usersAPI

View File

@@ -0,0 +1,26 @@
/**
* User Announcements API endpoints
*/
import { apiClient } from './client'
import type { UserAnnouncement } from '@/types'
export async function list(unreadOnly: boolean = false): Promise<UserAnnouncement[]> {
const { data } = await apiClient.get<UserAnnouncement[]>('/announcements', {
params: unreadOnly ? { unread_only: 1 } : {}
})
return data
}
export async function markRead(id: number): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>(`/announcements/${id}/read`)
return data
}
const announcementsAPI = {
list,
markRead
}
export default announcementsAPI

View File

@@ -0,0 +1,386 @@
/**
* Authentication API endpoints
* Handles user login, registration, and logout operations
*/
import { apiClient } from './client'
import type {
LoginRequest,
RegisterRequest,
AuthResponse,
CurrentUserResponse,
SendVerifyCodeRequest,
SendVerifyCodeResponse,
PublicSettings,
TotpLoginResponse,
TotpLogin2FARequest
} from '@/types'
/**
* Login response type - can be either full auth or 2FA required
*/
export type LoginResponse = AuthResponse | TotpLoginResponse
/**
* Type guard to check if login response requires 2FA
*/
export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse {
return 'requires_2fa' in response && response.requires_2fa === true
}
/**
* Store authentication token in localStorage
*/
export function setAuthToken(token: string): void {
localStorage.setItem('auth_token', token)
}
/**
* Store refresh token in localStorage
*/
export function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token)
}
/**
* Store token expiration timestamp in localStorage
* Converts expires_in (seconds) to absolute timestamp (milliseconds)
*/
export function setTokenExpiresAt(expiresIn: number): void {
const expiresAt = Date.now() + expiresIn * 1000
localStorage.setItem('token_expires_at', String(expiresAt))
}
/**
* Get authentication token from localStorage
*/
export function getAuthToken(): string | null {
return localStorage.getItem('auth_token')
}
/**
* Get refresh token from localStorage
*/
export function getRefreshToken(): string | null {
return localStorage.getItem('refresh_token')
}
/**
* Get token expiration timestamp from localStorage
*/
export function getTokenExpiresAt(): number | null {
const value = localStorage.getItem('token_expires_at')
return value ? parseInt(value, 10) : null
}
/**
* Clear authentication token from localStorage
*/
export function clearAuthToken(): void {
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('token_expires_at')
}
/**
* User login
* @param credentials - Email and password
* @returns Authentication response with token and user data, or 2FA required response
*/
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials)
// Only store token if 2FA is not required
if (!isTotp2FARequired(data)) {
setAuthToken(data.access_token)
if (data.refresh_token) {
setRefreshToken(data.refresh_token)
}
if (data.expires_in) {
setTokenExpiresAt(data.expires_in)
}
localStorage.setItem('auth_user', JSON.stringify(data.user))
}
return data
}
/**
* Complete login with 2FA code
* @param request - Temp token and TOTP code
* @returns Authentication response with token and user data
*/
export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request)
// Store token and user data
setAuthToken(data.access_token)
if (data.refresh_token) {
setRefreshToken(data.refresh_token)
}
if (data.expires_in) {
setTokenExpiresAt(data.expires_in)
}
localStorage.setItem('auth_user', JSON.stringify(data.user))
return data
}
/**
* User registration
* @param userData - Registration data (username, email, password)
* @returns Authentication response with token and user data
*/
export async function register(userData: RegisterRequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/register', userData)
// Store token and user data
setAuthToken(data.access_token)
if (data.refresh_token) {
setRefreshToken(data.refresh_token)
}
if (data.expires_in) {
setTokenExpiresAt(data.expires_in)
}
localStorage.setItem('auth_user', JSON.stringify(data.user))
return data
}
/**
* Get current authenticated user
* @returns User profile data
*/
export async function getCurrentUser() {
return apiClient.get<CurrentUserResponse>('/auth/me')
}
/**
* User logout
* Clears authentication token and user data from localStorage
* Optionally revokes the refresh token on the server
*/
export async function logout(): Promise<void> {
const refreshToken = getRefreshToken()
// Try to revoke the refresh token on the server
if (refreshToken) {
try {
await apiClient.post('/auth/logout', { refresh_token: refreshToken })
} catch {
// Ignore errors - we still want to clear local state
}
}
clearAuthToken()
}
/**
* Refresh token response
*/
export interface RefreshTokenResponse {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
/**
* Refresh the access token using the refresh token
* @returns New token pair
*/
export async function refreshToken(): Promise<RefreshTokenResponse> {
const currentRefreshToken = getRefreshToken()
if (!currentRefreshToken) {
throw new Error('No refresh token available')
}
const { data } = await apiClient.post<RefreshTokenResponse>('/auth/refresh', {
refresh_token: currentRefreshToken
})
// Update tokens in localStorage
setAuthToken(data.access_token)
setRefreshToken(data.refresh_token)
setTokenExpiresAt(data.expires_in)
return data
}
/**
* Revoke all sessions for the current user
* @returns Response with message
*/
export async function revokeAllSessions(): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>('/auth/revoke-all-sessions')
return data
}
/**
* Check if user is authenticated
* @returns True if user has valid token
*/
export function isAuthenticated(): boolean {
return getAuthToken() !== null
}
/**
* Get public settings (no auth required)
* @returns Public settings including registration and Turnstile config
*/
export async function getPublicSettings(): Promise<PublicSettings> {
const { data } = await apiClient.get<PublicSettings>('/settings/public')
return data
}
/**
* Send verification code to email
* @param request - Email and optional Turnstile token
* @returns Response with countdown seconds
*/
export async function sendVerifyCode(
request: SendVerifyCodeRequest
): Promise<SendVerifyCodeResponse> {
const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request)
return data
}
/**
* Validate promo code response
*/
export interface ValidatePromoCodeResponse {
valid: boolean
bonus_amount?: number
error_code?: string
message?: string
}
/**
* Validate promo code (public endpoint, no auth required)
* @param code - Promo code to validate
* @returns Validation result with bonus amount if valid
*/
export async function validatePromoCode(code: string): Promise<ValidatePromoCodeResponse> {
const { data } = await apiClient.post<ValidatePromoCodeResponse>('/auth/validate-promo-code', { code })
return data
}
/**
* Validate invitation code response
*/
export interface ValidateInvitationCodeResponse {
valid: boolean
error_code?: string
}
/**
* Validate invitation code (public endpoint, no auth required)
* @param code - Invitation code to validate
* @returns Validation result
*/
export async function validateInvitationCode(code: string): Promise<ValidateInvitationCodeResponse> {
const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code })
return data
}
/**
* Forgot password request
*/
export interface ForgotPasswordRequest {
email: string
turnstile_token?: string
}
/**
* Forgot password response
*/
export interface ForgotPasswordResponse {
message: string
}
/**
* Request password reset link
* @param request - Email and optional Turnstile token
* @returns Response with message
*/
export async function forgotPassword(request: ForgotPasswordRequest): Promise<ForgotPasswordResponse> {
const { data } = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', request)
return data
}
/**
* Reset password request
*/
export interface ResetPasswordRequest {
email: string
token: string
new_password: string
}
/**
* Reset password response
*/
export interface ResetPasswordResponse {
message: string
}
/**
* Reset password with token
* @param request - Email, token, and new password
* @returns Response with message
*/
export async function resetPassword(request: ResetPasswordRequest): Promise<ResetPasswordResponse> {
const { data } = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', request)
return data
}
/**
* Complete LinuxDo OAuth registration by supplying an invitation code
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export async function completeLinuxDoOAuthRegistration(
pendingOAuthToken: string,
invitationCode: string
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
const { data } = await apiClient.post<{
access_token: string
refresh_token: string
expires_in: number
token_type: string
}>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: pendingOAuthToken,
invitation_code: invitationCode
})
return data
}
export const authAPI = {
login,
login2FA,
isTotp2FARequired,
register,
getCurrentUser,
logout,
isAuthenticated,
setAuthToken,
setRefreshToken,
setTokenExpiresAt,
getAuthToken,
getRefreshToken,
getTokenExpiresAt,
clearAuthToken,
getPublicSettings,
sendVerifyCode,
validatePromoCode,
validateInvitationCode,
forgotPassword,
resetPassword,
refreshToken,
revokeAllSessions,
completeLinuxDoOAuthRegistration
}
export default authAPI

View File

@@ -0,0 +1,283 @@
/**
* Axios HTTP Client Configuration
* Base client with interceptors for authentication, token refresh, and error handling
*/
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types'
import { getLocale } from '@/i18n'
// ==================== Axios Instance Configuration ====================
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
export const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// ==================== Token Refresh State ====================
// Track if a token refresh is in progress to prevent multiple simultaneous refresh requests
let isRefreshing = false
// Queue of requests waiting for token refresh
let refreshSubscribers: Array<(token: string) => void> = []
/**
* Subscribe to token refresh completion
*/
function subscribeTokenRefresh(callback: (token: string) => void): void {
refreshSubscribers.push(callback)
}
/**
* Notify all subscribers that token has been refreshed
*/
function onTokenRefreshed(token: string): void {
refreshSubscribers.forEach((callback) => callback(token))
refreshSubscribers = []
}
// ==================== Request Interceptor ====================
// Get user's timezone
const getUserTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
return 'UTC'
}
}
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Attach token from localStorage
const token = localStorage.getItem('auth_token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
// Attach locale for backend translations
if (config.headers) {
config.headers['Accept-Language'] = getLocale()
}
// Attach timezone for all GET requests (backend may use it for default date ranges)
if (config.method === 'get') {
if (!config.params) {
config.params = {}
}
config.params.timezone = getUserTimezone()
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// ==================== Response Interceptor ====================
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
// Unwrap standard API response format { code, message, data }
const apiResponse = response.data as ApiResponse<unknown>
if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) {
if (apiResponse.code === 0) {
// Success - return the data portion
response.data = apiResponse.data
} else {
// API error
return Promise.reject({
status: response.status,
code: apiResponse.code,
message: apiResponse.message || 'Unknown error'
})
}
}
return response
},
async (error: AxiosError<ApiResponse<unknown>>) => {
// Request cancellation: keep the original axios cancellation error so callers can ignore it.
// Otherwise we'd misclassify it as a generic "network error".
if (error.code === 'ERR_CANCELED' || axios.isCancel(error)) {
return Promise.reject(error)
}
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// Handle common errors
if (error.response) {
const { status, data } = error.response
const url = String(error.config?.url || '')
// Validate `data` shape to avoid HTML error pages breaking our error handling.
const apiData = (typeof data === 'object' && data !== null ? data : {}) as Record<string, any>
// Ops monitoring disabled: treat as feature-flagged 404, and proactively redirect away
// from ops pages to avoid broken UI states.
if (status === 404 && apiData.message === 'Ops monitoring is disabled') {
try {
localStorage.setItem('ops_monitoring_enabled_cached', 'false')
} catch {
// ignore localStorage failures
}
try {
window.dispatchEvent(new CustomEvent('ops-monitoring-disabled'))
} catch {
// ignore event failures
}
if (window.location.pathname.startsWith('/admin/ops')) {
window.location.href = '/admin/settings'
}
return Promise.reject({
status,
code: 'OPS_DISABLED',
message: apiData.message || error.message,
url
})
}
// 401: Try to refresh the token if we have a refresh token
// This handles TOKEN_EXPIRED, INVALID_TOKEN, TOKEN_REVOKED, etc.
if (status === 401 && !originalRequest._retry) {
const refreshToken = localStorage.getItem('refresh_token')
const isAuthEndpoint =
url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh')
// If we have a refresh token and this is not an auth endpoint, try to refresh
if (refreshToken && !isAuthEndpoint) {
if (isRefreshing) {
// Wait for the ongoing refresh to complete
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken: string) => {
if (newToken) {
// Mark as retried to prevent infinite loop if retry also returns 401
originalRequest._retry = true
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
resolve(apiClient(originalRequest))
} else {
// Refresh failed, reject with original error
reject({
status,
code: apiData.code,
message: apiData.message || apiData.detail || error.message
})
}
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
// Call refresh endpoint directly to avoid circular dependency
const refreshResponse = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{ refresh_token: refreshToken },
{ headers: { 'Content-Type': 'application/json' } }
)
const refreshData = refreshResponse.data as ApiResponse<{
access_token: string
refresh_token: string
expires_in: number
}>
if (refreshData.code === 0 && refreshData.data) {
const { access_token, refresh_token: newRefreshToken, expires_in } = refreshData.data
// Update tokens in localStorage (convert expires_in to timestamp)
localStorage.setItem('auth_token', access_token)
localStorage.setItem('refresh_token', newRefreshToken)
localStorage.setItem('token_expires_at', String(Date.now() + expires_in * 1000))
// Notify subscribers with new token
onTokenRefreshed(access_token)
// Retry the original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${access_token}`
}
isRefreshing = false
return apiClient(originalRequest)
}
// Refresh response was not successful, fall through to clear auth
throw new Error('Token refresh failed')
} catch (refreshError) {
// Refresh failed - notify subscribers with empty token
onTokenRefreshed('')
isRefreshing = false
// Clear tokens and redirect to login
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('token_expires_at')
sessionStorage.setItem('auth_expired', '1')
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
return Promise.reject({
status: 401,
code: 'TOKEN_REFRESH_FAILED',
message: 'Session expired. Please log in again.'
})
}
}
// No refresh token or is auth endpoint - clear auth and redirect
const hasToken = !!localStorage.getItem('auth_token')
const headers = error.config?.headers as Record<string, unknown> | undefined
const authHeader = headers?.Authorization ?? headers?.authorization
const sentAuth =
typeof authHeader === 'string'
? authHeader.trim() !== ''
: Array.isArray(authHeader)
? authHeader.length > 0
: !!authHeader
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('token_expires_at')
if ((hasToken || sentAuth) && !isAuthEndpoint) {
sessionStorage.setItem('auth_expired', '1')
}
// Only redirect if not already on login page
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
// Return structured error
return Promise.reject({
status,
code: apiData.code,
error: apiData.error,
message: apiData.message || apiData.detail || error.message
})
}
// Network error
return Promise.reject({
status: 0,
message: 'Network error. Please check your connection.'
})
}
)
export default apiClient

View File

@@ -0,0 +1,35 @@
/**
* User Groups API endpoints (non-admin)
* Handles group-related operations for regular users
*/
import { apiClient } from './client'
import type { Group } from '@/types'
/**
* Get available groups that the current user can bind to API keys
* This returns groups based on user's permissions:
* - Standard groups: public (non-exclusive) or explicitly allowed
* - Subscription groups: user has active subscription
* @returns List of available groups
*/
export async function getAvailable(): Promise<Group[]> {
const { data } = await apiClient.get<Group[]>('/groups/available')
return data
}
/**
* Get current user's custom group rate multipliers
* @returns Map of group_id to custom rate_multiplier
*/
export async function getUserGroupRates(): Promise<Record<number, number>> {
const { data } = await apiClient.get<Record<number, number> | null>('/groups/rates')
return data || {}
}
export const userGroupsAPI = {
getAvailable,
getUserGroupRates
}
export default userGroupsAPI

View File

@@ -0,0 +1,25 @@
/**
* API Client for Sub2API Backend
* Central export point for all API modules
*/
// Re-export the HTTP client
export { apiClient } from './client'
// Auth API
export { authAPI, isTotp2FARequired, type LoginResponse } from './auth'
// User APIs
export { keysAPI } from './keys'
export { usageAPI } from './usage'
export { userAPI } from './user'
export { redeemAPI, type RedeemHistoryItem } from './redeem'
export { userGroupsAPI } from './groups'
export { totpAPI } from './totp'
export { default as announcementsAPI } from './announcements'
// Admin APIs
export { adminAPI } from './admin'
// Default export
export { default } from './client'

View File

@@ -0,0 +1,137 @@
/**
* API Keys management endpoints
* Handles CRUD operations for user API keys
*/
import { apiClient } from './client'
import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedResponse } from '@/types'
/**
* List all API keys for current user
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 10)
* @param filters - Optional filter parameters
* @param options - Optional request options
* @returns Paginated list of API keys
*/
export async function list(
page: number = 1,
pageSize: number = 10,
filters?: { search?: string; status?: string; group_id?: number | string },
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', {
params: { page, page_size: pageSize, ...filters },
signal: options?.signal
})
return data
}
/**
* Get API key by ID
* @param id - API key ID
* @returns API key details
*/
export async function getById(id: number): Promise<ApiKey> {
const { data } = await apiClient.get<ApiKey>(`/keys/${id}`)
return data
}
/**
* Create new API key
* @param name - Key name
* @param groupId - Optional group ID
* @param customKey - Optional custom key value
* @param ipWhitelist - Optional IP whitelist
* @param ipBlacklist - Optional IP blacklist
* @param quota - Optional quota limit in USD (0 = unlimited)
* @param expiresInDays - Optional days until expiry (undefined = never expires)
* @param rateLimitData - Optional rate limit fields
* @returns Created API key
*/
export async function create(
name: string,
groupId?: number | null,
customKey?: string,
ipWhitelist?: string[],
ipBlacklist?: string[],
quota?: number,
expiresInDays?: number,
rateLimitData?: { rate_limit_5h?: number; rate_limit_1d?: number; rate_limit_7d?: number }
): Promise<ApiKey> {
const payload: CreateApiKeyRequest = { name }
if (groupId !== undefined) {
payload.group_id = groupId
}
if (customKey) {
payload.custom_key = customKey
}
if (ipWhitelist && ipWhitelist.length > 0) {
payload.ip_whitelist = ipWhitelist
}
if (ipBlacklist && ipBlacklist.length > 0) {
payload.ip_blacklist = ipBlacklist
}
if (quota !== undefined && quota > 0) {
payload.quota = quota
}
if (expiresInDays !== undefined && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
if (rateLimitData?.rate_limit_5h && rateLimitData.rate_limit_5h > 0) {
payload.rate_limit_5h = rateLimitData.rate_limit_5h
}
if (rateLimitData?.rate_limit_1d && rateLimitData.rate_limit_1d > 0) {
payload.rate_limit_1d = rateLimitData.rate_limit_1d
}
if (rateLimitData?.rate_limit_7d && rateLimitData.rate_limit_7d > 0) {
payload.rate_limit_7d = rateLimitData.rate_limit_7d
}
const { data } = await apiClient.post<ApiKey>('/keys', payload)
return data
}
/**
* Update API key
* @param id - API key ID
* @param updates - Fields to update
* @returns Updated API key
*/
export async function update(id: number, updates: UpdateApiKeyRequest): Promise<ApiKey> {
const { data } = await apiClient.put<ApiKey>(`/keys/${id}`, updates)
return data
}
/**
* Delete API key
* @param id - API key ID
* @returns Success confirmation
*/
export async function deleteKey(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/keys/${id}`)
return data
}
/**
* Toggle API key status (active/inactive)
* @param id - API key ID
* @param status - New status
* @returns Updated API key
*/
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<ApiKey> {
return update(id, { status })
}
export const keysAPI = {
list,
getById,
create,
update,
delete: deleteKey,
toggleStatus
}
export default keysAPI

View File

@@ -0,0 +1,67 @@
/**
* Redeem code API endpoints
* Handles redeem code redemption for users
*/
import { apiClient } from './client'
import type { RedeemCodeRequest } from '@/types'
export interface RedeemHistoryItem {
id: number
code: string
type: string
value: number
status: string
used_at: string
created_at: string
// Notes from admin for admin_balance/admin_concurrency types
notes?: string
// Subscription-specific fields
group_id?: number
validity_days?: number
group?: {
id: number
name: string
}
}
/**
* Redeem a code
* @param code - Redeem code string
* @returns Redemption result with updated balance or concurrency
*/
export async function redeem(code: string): Promise<{
message: string
type: string
value: number
new_balance?: number
new_concurrency?: number
}> {
const payload: RedeemCodeRequest = { code }
const { data } = await apiClient.post<{
message: string
type: string
value: number
new_balance?: number
new_concurrency?: number
}>('/redeem', payload)
return data
}
/**
* Get user's redemption history
* @returns List of redeemed codes
*/
export async function getHistory(): Promise<RedeemHistoryItem[]> {
const { data } = await apiClient.get<RedeemHistoryItem[]>('/redeem/history')
return data
}
export const redeemAPI = {
redeem,
getHistory
}
export default redeemAPI

View File

@@ -0,0 +1,88 @@
/**
* Setup API endpoints
*/
import axios from 'axios'
// Create a separate client for setup endpoints (not under /api/v1)
const setupClient = axios.create({
baseURL: '',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
export interface SetupStatus {
needs_setup: boolean
step: string
}
export interface DatabaseConfig {
host: string
port: number
user: string
password: string
dbname: string
sslmode: string
}
export interface RedisConfig {
host: string
port: number
password: string
db: number
enable_tls: boolean
}
export interface AdminConfig {
email: string
password: string
}
export interface ServerConfig {
host: string
port: number
mode: string
}
export interface InstallRequest {
database: DatabaseConfig
redis: RedisConfig
admin: AdminConfig
server: ServerConfig
}
export interface InstallResponse {
message: string
restart: boolean
}
/**
* Get setup status
*/
export async function getSetupStatus(): Promise<SetupStatus> {
const response = await setupClient.get('/setup/status')
return response.data.data
}
/**
* Test database connection
*/
export async function testDatabase(config: DatabaseConfig): Promise<void> {
await setupClient.post('/setup/test-db', config)
}
/**
* Test Redis connection
*/
export async function testRedis(config: RedisConfig): Promise<void> {
await setupClient.post('/setup/test-redis', config)
}
/**
* Perform installation
*/
export async function install(config: InstallRequest): Promise<InstallResponse> {
const response = await setupClient.post('/setup/install', config)
return response.data.data
}

View File

@@ -0,0 +1,307 @@
/**
* Sora 客户端 API
* 封装所有 Sora 生成、作品库、配额等接口调用
*/
import { apiClient } from './client'
// ==================== 类型定义 ====================
export interface SoraGeneration {
id: number
user_id: number
model: string
prompt: string
media_type: string
status: string // pending | generating | completed | failed | cancelled
storage_type: string // upstream | s3 | local
media_url: string
media_urls: string[]
s3_object_keys: string[]
file_size_bytes: number
error_message: string
created_at: string
completed_at?: string
}
export interface GenerateRequest {
model: string
prompt: string
video_count?: number
media_type?: string
image_input?: string
api_key_id?: number
}
export interface GenerateResponse {
generation_id: number
status: string
}
export interface GenerationListResponse {
data: SoraGeneration[]
total: number
page: number
}
export interface QuotaInfo {
quota_bytes: number
used_bytes: number
available_bytes: number
quota_source: string // user | group | system | unlimited
source?: string // 兼容旧字段
}
export interface StorageStatus {
s3_enabled: boolean
s3_healthy: boolean
local_enabled: boolean
}
/** 单个扁平模型(旧接口,保留兼容) */
export interface SoraModel {
id: string
name: string
type: string // video | image
orientation?: string
duration?: number
}
/** 模型家族(新接口 — 后端从 soraModelConfigs 自动聚合) */
export interface SoraModelFamily {
id: string // 家族 ID如 "sora2"
name: string // 显示名,如 "Sora 2"
type: string // "video" | "image"
orientations: string[] // ["landscape", "portrait"] 或 ["landscape", "portrait", "square"]
durations?: number[] // [10, 15, 25](仅视频模型)
}
type LooseRecord = Record<string, unknown>
function asRecord(value: unknown): LooseRecord | null {
return value !== null && typeof value === 'object' ? value as LooseRecord : null
}
function asArray<T = unknown>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : []
}
function asPositiveInt(value: unknown): number | null {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return null
return Math.round(n)
}
function dedupeStrings(values: string[]): string[] {
return Array.from(new Set(values))
}
function extractOrientationFromModelID(modelID: string): string | null {
const m = modelID.match(/-(landscape|portrait|square)(?:-\d+s)?$/i)
return m ? m[1].toLowerCase() : null
}
function extractDurationFromModelID(modelID: string): number | null {
const m = modelID.match(/-(\d+)s$/i)
return m ? asPositiveInt(m[1]) : null
}
function normalizeLegacyFamilies(candidates: unknown[]): SoraModelFamily[] {
const familyMap = new Map<string, SoraModelFamily>()
for (const item of candidates) {
const model = asRecord(item)
if (!model || typeof model.id !== 'string' || model.id.trim() === '') continue
const rawID = model.id.trim()
const type = model.type === 'image' ? 'image' : 'video'
const name = typeof model.name === 'string' && model.name.trim() ? model.name.trim() : rawID
const baseID = rawID.replace(/-(landscape|portrait|square)(?:-\d+s)?$/i, '')
const orientation =
typeof model.orientation === 'string' && model.orientation
? model.orientation.toLowerCase()
: extractOrientationFromModelID(rawID)
const duration = asPositiveInt(model.duration) ?? extractDurationFromModelID(rawID)
const familyKey = baseID || rawID
const family = familyMap.get(familyKey) ?? {
id: familyKey,
name,
type,
orientations: [],
durations: []
}
if (orientation) {
family.orientations.push(orientation)
}
if (type === 'video' && duration) {
family.durations = family.durations || []
family.durations.push(duration)
}
familyMap.set(familyKey, family)
}
return Array.from(familyMap.values())
.map((family) => ({
...family,
orientations:
family.orientations.length > 0
? dedupeStrings(family.orientations)
: (family.type === 'image' ? ['square'] : ['landscape']),
durations:
family.type === 'video'
? Array.from(new Set((family.durations || []).filter((d): d is number => Number.isFinite(d)))).sort((a, b) => a - b)
: []
}))
.filter((family) => family.id !== '')
}
function normalizeModelFamilyRecord(item: unknown): SoraModelFamily | null {
const model = asRecord(item)
if (!model || typeof model.id !== 'string' || model.id.trim() === '') return null
// 仅把明确的“家族结构”识别为 family老结构单模型走 legacy 聚合逻辑。
if (!Array.isArray(model.orientations) && !Array.isArray(model.durations)) return null
const orientations = asArray<string>(model.orientations).filter((o): o is string => typeof o === 'string' && o.length > 0)
const durations = asArray<unknown>(model.durations)
.map(asPositiveInt)
.filter((d): d is number => d !== null)
return {
id: model.id.trim(),
name: typeof model.name === 'string' && model.name.trim() ? model.name.trim() : model.id.trim(),
type: model.type === 'image' ? 'image' : 'video',
orientations: dedupeStrings(orientations),
durations: Array.from(new Set(durations)).sort((a, b) => a - b)
}
}
function extractCandidateArray(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload
const record = asRecord(payload)
if (!record) return []
const keys: Array<keyof LooseRecord> = ['data', 'items', 'models', 'families']
for (const key of keys) {
if (Array.isArray(record[key])) {
return record[key] as unknown[]
}
}
return []
}
export function normalizeModelFamiliesResponse(payload: unknown): SoraModelFamily[] {
const candidates = extractCandidateArray(payload)
if (candidates.length === 0) return []
const normalized = candidates
.map(normalizeModelFamilyRecord)
.filter((item): item is SoraModelFamily => item !== null)
if (normalized.length > 0) return normalized
return normalizeLegacyFamilies(candidates)
}
export function normalizeGenerationListResponse(payload: unknown): GenerationListResponse {
const record = asRecord(payload)
if (!record) {
return { data: [], total: 0, page: 1 }
}
const data = Array.isArray(record.data)
? (record.data as SoraGeneration[])
: Array.isArray(record.items)
? (record.items as SoraGeneration[])
: []
const total = Number(record.total)
const page = Number(record.page)
return {
data,
total: Number.isFinite(total) ? total : data.length,
page: Number.isFinite(page) && page > 0 ? page : 1
}
}
// ==================== API 方法 ====================
/** 异步生成 — 创建 pending 记录后立即返回 */
export async function generate(req: GenerateRequest): Promise<GenerateResponse> {
const { data } = await apiClient.post<GenerateResponse>('/sora/generate', req)
return data
}
/** 查询生成记录列表 */
export async function listGenerations(params?: {
page?: number
page_size?: number
status?: string
storage_type?: string
media_type?: string
}): Promise<GenerationListResponse> {
const { data } = await apiClient.get<unknown>('/sora/generations', { params })
return normalizeGenerationListResponse(data)
}
/** 查询生成记录详情 */
export async function getGeneration(id: number): Promise<SoraGeneration> {
const { data } = await apiClient.get<SoraGeneration>(`/sora/generations/${id}`)
return data
}
/** 删除生成记录 */
export async function deleteGeneration(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/sora/generations/${id}`)
return data
}
/** 取消生成任务 */
export async function cancelGeneration(id: number): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>(`/sora/generations/${id}/cancel`)
return data
}
/** 手动保存到 S3 */
export async function saveToStorage(
id: number
): Promise<{ message: string; object_key: string; object_keys?: string[] }> {
const { data } = await apiClient.post<{ message: string; object_key: string; object_keys?: string[] }>(
`/sora/generations/${id}/save`
)
return data
}
/** 查询配额信息 */
export async function getQuota(): Promise<QuotaInfo> {
const { data } = await apiClient.get<QuotaInfo>('/sora/quota')
return data
}
/** 获取可用模型家族列表 */
export async function getModels(): Promise<SoraModelFamily[]> {
const { data } = await apiClient.get<unknown>('/sora/models')
return normalizeModelFamiliesResponse(data)
}
/** 获取存储状态 */
export async function getStorageStatus(): Promise<StorageStatus> {
const { data } = await apiClient.get<StorageStatus>('/sora/storage-status')
return data
}
const soraAPI = {
generate,
listGenerations,
getGeneration,
deleteGeneration,
cancelGeneration,
saveToStorage,
getQuota,
getModels,
getStorageStatus
}
export default soraAPI

View File

@@ -0,0 +1,76 @@
/**
* User Subscription API
* API for regular users to view their own subscriptions and progress
*/
import { apiClient } from './client'
import type { UserSubscription, SubscriptionProgress } from '@/types'
/**
* Subscription summary for user dashboard
*/
export interface SubscriptionSummary {
active_count: number
subscriptions: Array<{
id: number
group_name: string
status: string
daily_progress: number | null
weekly_progress: number | null
monthly_progress: number | null
expires_at: string | null
days_remaining: number | null
}>
}
/**
* Get list of current user's subscriptions
*/
export async function getMySubscriptions(): Promise<UserSubscription[]> {
const response = await apiClient.get<UserSubscription[]>('/subscriptions')
return response.data
}
/**
* Get current user's active subscriptions
*/
export async function getActiveSubscriptions(): Promise<UserSubscription[]> {
const response = await apiClient.get<UserSubscription[]>('/subscriptions/active')
return response.data
}
/**
* Get progress for all user's active subscriptions
*/
export async function getSubscriptionsProgress(): Promise<SubscriptionProgress[]> {
const response = await apiClient.get<SubscriptionProgress[]>('/subscriptions/progress')
return response.data
}
/**
* Get subscription summary for dashboard display
*/
export async function getSubscriptionSummary(): Promise<SubscriptionSummary> {
const response = await apiClient.get<SubscriptionSummary>('/subscriptions/summary')
return response.data
}
/**
* Get progress for a specific subscription
*/
export async function getSubscriptionProgress(
subscriptionId: number
): Promise<SubscriptionProgress> {
const response = await apiClient.get<SubscriptionProgress>(
`/subscriptions/${subscriptionId}/progress`
)
return response.data
}
export default {
getMySubscriptions,
getActiveSubscriptions,
getSubscriptionsProgress,
getSubscriptionSummary,
getSubscriptionProgress
}

View File

@@ -0,0 +1,83 @@
/**
* TOTP (2FA) API endpoints
* Handles Two-Factor Authentication with Google Authenticator
*/
import { apiClient } from './client'
import type {
TotpStatus,
TotpSetupRequest,
TotpSetupResponse,
TotpEnableRequest,
TotpEnableResponse,
TotpDisableRequest,
TotpVerificationMethod
} from '@/types'
/**
* Get TOTP status for current user
* @returns TOTP status including enabled state and feature availability
*/
export async function getStatus(): Promise<TotpStatus> {
const { data } = await apiClient.get<TotpStatus>('/user/totp/status')
return data
}
/**
* Get verification method for TOTP operations
* @returns Method ('email' or 'password') required for setup/disable
*/
export async function getVerificationMethod(): Promise<TotpVerificationMethod> {
const { data } = await apiClient.get<TotpVerificationMethod>('/user/totp/verification-method')
return data
}
/**
* Send email verification code for TOTP operations
* @returns Success response
*/
export async function sendVerifyCode(): Promise<{ success: boolean }> {
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/send-code')
return data
}
/**
* Initiate TOTP setup - generates secret and QR code
* @param request - Email code or password depending on verification method
* @returns Setup response with secret, QR code URL, and setup token
*/
export async function initiateSetup(request?: TotpSetupRequest): Promise<TotpSetupResponse> {
const { data } = await apiClient.post<TotpSetupResponse>('/user/totp/setup', request || {})
return data
}
/**
* Complete TOTP setup by verifying the code
* @param request - TOTP code and setup token
* @returns Enable response with success status and enabled timestamp
*/
export async function enable(request: TotpEnableRequest): Promise<TotpEnableResponse> {
const { data } = await apiClient.post<TotpEnableResponse>('/user/totp/enable', request)
return data
}
/**
* Disable TOTP for current user
* @param request - Email code or password depending on verification method
* @returns Success response
*/
export async function disable(request: TotpDisableRequest): Promise<{ success: boolean }> {
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/disable', request)
return data
}
export const totpAPI = {
getStatus,
getVerificationMethod,
sendVerifyCode,
initiateSetup,
enable,
disable
}
export default totpAPI

View File

@@ -0,0 +1,274 @@
/**
* Usage tracking API endpoints
* Handles usage logs and statistics retrieval
*/
import { apiClient } from './client'
import type {
UsageLog,
UsageQueryParams,
UsageStatsResponse,
PaginatedResponse,
TrendDataPoint,
ModelStat
} from '@/types'
// ==================== Dashboard Types ====================
export interface UserDashboardStats {
total_api_keys: number
active_api_keys: number
total_requests: number
total_input_tokens: number
total_output_tokens: number
total_cache_creation_tokens: number
total_cache_read_tokens: number
total_tokens: number
total_cost: number // 标准计费
total_actual_cost: number // 实际扣除
today_requests: number
today_input_tokens: number
today_output_tokens: number
today_cache_creation_tokens: number
today_cache_read_tokens: number
today_tokens: number
today_cost: number // 今日标准计费
today_actual_cost: number // 今日实际扣除
average_duration_ms: number
rpm: number // 近5分钟平均每分钟请求数
tpm: number // 近5分钟平均每分钟Token数
}
export interface TrendParams {
start_date?: string
end_date?: string
granularity?: 'day' | 'hour'
}
export interface TrendResponse {
trend: TrendDataPoint[]
start_date: string
end_date: string
granularity: string
}
export interface ModelStatsResponse {
models: ModelStat[]
start_date: string
end_date: string
}
/**
* List usage logs with optional filters
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param apiKeyId - Filter by API key ID
* @returns Paginated list of usage logs
*/
export async function list(
page: number = 1,
pageSize: number = 20,
apiKeyId?: number
): Promise<PaginatedResponse<UsageLog>> {
const params: UsageQueryParams = {
page,
page_size: pageSize
}
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId
}
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
params
})
return data
}
/**
* Get usage logs with advanced query parameters
* @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs
*/
export async function query(
params: UsageQueryParams,
config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
...config,
params
})
return data
}
/**
* Get usage statistics for a specific period
* @param period - Time period ('today', 'week', 'month', 'year')
* @param apiKeyId - Optional API key ID filter
* @returns Usage statistics
*/
export async function getStats(
period: string = 'today',
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = { period }
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId
}
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
params
})
return data
}
/**
* Get usage statistics for a date range
* @param startDate - Start date (YYYY-MM-DD format)
* @param endDate - End date (YYYY-MM-DD format)
* @param apiKeyId - Optional API key ID filter
* @returns Usage statistics
*/
export async function getStatsByDateRange(
startDate: string,
endDate: string,
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = {
start_date: startDate,
end_date: endDate
}
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId
}
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
params
})
return data
}
/**
* Get usage by date range
* @param startDate - Start date (YYYY-MM-DD format)
* @param endDate - End date (YYYY-MM-DD format)
* @param apiKeyId - Optional API key ID filter
* @returns Usage logs within date range
*/
export async function getByDateRange(
startDate: string,
endDate: string,
apiKeyId?: number
): Promise<PaginatedResponse<UsageLog>> {
const params: UsageQueryParams = {
start_date: startDate,
end_date: endDate,
page: 1,
page_size: 100
}
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId
}
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
params
})
return data
}
/**
* Get detailed usage log by ID
* @param id - Usage log ID
* @returns Usage log details
*/
export async function getById(id: number): Promise<UsageLog> {
const { data } = await apiClient.get<UsageLog>(`/usage/${id}`)
return data
}
// ==================== Dashboard API ====================
/**
* Get user dashboard statistics
* @returns Dashboard statistics for current user
*/
export async function getDashboardStats(): Promise<UserDashboardStats> {
const { data } = await apiClient.get<UserDashboardStats>('/usage/dashboard/stats')
return data
}
/**
* Get user usage trend data
* @param params - Query parameters for filtering
* @returns Usage trend data for current user
*/
export async function getDashboardTrend(params?: TrendParams): Promise<TrendResponse> {
const { data } = await apiClient.get<TrendResponse>('/usage/dashboard/trend', { params })
return data
}
/**
* Get user model usage statistics
* @param params - Query parameters for filtering
* @returns Model usage statistics for current user
*/
export async function getDashboardModels(params?: {
start_date?: string
end_date?: string
}): Promise<ModelStatsResponse> {
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params })
return data
}
export interface BatchApiKeyUsageStats {
api_key_id: number
today_actual_cost: number
total_actual_cost: number
}
export interface BatchApiKeysUsageResponse {
stats: Record<string, BatchApiKeyUsageStats>
}
/**
* Get batch usage stats for user's own API keys
* @param apiKeyIds - Array of API key IDs
* @param options - Optional request options
* @returns Usage stats map keyed by API key ID
*/
export async function getDashboardApiKeysUsage(
apiKeyIds: number[],
options?: {
signal?: AbortSignal
}
): Promise<BatchApiKeysUsageResponse> {
const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
'/usage/dashboard/api-keys-usage',
{
api_key_ids: apiKeyIds
},
{
signal: options?.signal
}
)
return data
}
export const usageAPI = {
list,
query,
getStats,
getStatsByDateRange,
getByDateRange,
getById,
// Dashboard
getDashboardStats,
getDashboardTrend,
getDashboardModels,
getDashboardApiKeysUsage
}
export default usageAPI

View File

@@ -0,0 +1,54 @@
/**
* User API endpoints
* Handles user profile management and password changes
*/
import { apiClient } from './client'
import type { User, ChangePasswordRequest } from '@/types'
/**
* Get current user profile
* @returns User profile data
*/
export async function getProfile(): Promise<User> {
const { data } = await apiClient.get<User>('/user/profile')
return data
}
/**
* Update current user profile
* @param profile - Profile data to update
* @returns Updated user profile data
*/
export async function updateProfile(profile: {
username?: string
}): Promise<User> {
const { data } = await apiClient.put<User>('/user', profile)
return data
}
/**
* Change current user password
* @param passwords - Old and new password
* @returns Success message
*/
export async function changePassword(
oldPassword: string,
newPassword: string
): Promise<{ message: string }> {
const payload: ChangePasswordRequest = {
old_password: oldPassword,
new_password: newPassword
}
const { data } = await apiClient.put<{ message: string }>('/user/password', payload)
return data
}
export const userAPI = {
getProfile,
updateProfile,
changePassword
}
export default userAPI

View File

@@ -0,0 +1,309 @@
import { DriveStep } from 'driver.js'
/**
* 管理员完整引导流程
* 交互式引导:指引用户实际操作
* @param t 国际化函数
* @param isSimpleMode 是否为简易模式(简易模式下会过滤分组相关步骤)
*/
export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false): DriveStep[] => {
const allSteps: DriveStep[] = [
// ========== 欢迎介绍 ==========
{
popover: {
title: t('onboarding.admin.welcome.title'),
description: t('onboarding.admin.welcome.description'),
align: 'center',
nextBtnText: t('onboarding.admin.welcome.nextBtn'),
prevBtnText: t('onboarding.admin.welcome.prevBtn')
}
},
// ========== 第一部分:创建分组 ==========
{
element: '#sidebar-group-manage',
popover: {
title: t('onboarding.admin.groupManage.title'),
description: t('onboarding.admin.groupManage.description'),
side: 'right',
align: 'center',
showButtons: ['close'],
}
},
{
element: '[data-tour="groups-create-btn"]',
popover: {
title: t('onboarding.admin.createGroup.title'),
description: t('onboarding.admin.createGroup.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="group-form-name"]',
popover: {
title: t('onboarding.admin.groupName.title'),
description: t('onboarding.admin.groupName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-platform"]',
popover: {
title: t('onboarding.admin.groupPlatform.title'),
description: t('onboarding.admin.groupPlatform.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-multiplier"]',
popover: {
title: t('onboarding.admin.groupMultiplier.title'),
description: t('onboarding.admin.groupMultiplier.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-exclusive"]',
popover: {
title: t('onboarding.admin.groupExclusive.title'),
description: t('onboarding.admin.groupExclusive.description'),
side: 'top',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-submit"]',
popover: {
title: t('onboarding.admin.groupSubmit.title'),
description: t('onboarding.admin.groupSubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
},
// ========== 第二部分:创建账号授权 ==========
{
element: '#sidebar-channel-manage',
popover: {
title: t('onboarding.admin.accountManage.title'),
description: t('onboarding.admin.accountManage.description'),
side: 'right',
align: 'center',
showButtons: ['close']
}
},
{
element: '[data-tour="accounts-create-btn"]',
popover: {
title: t('onboarding.admin.createAccount.title'),
description: t('onboarding.admin.createAccount.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="account-form-name"]',
popover: {
title: t('onboarding.admin.accountName.title'),
description: t('onboarding.admin.accountName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-platform"]',
popover: {
title: t('onboarding.admin.accountPlatform.title'),
description: t('onboarding.admin.accountPlatform.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-type"]',
popover: {
title: t('onboarding.admin.accountType.title'),
description: t('onboarding.admin.accountType.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-priority"]',
popover: {
title: t('onboarding.admin.accountPriority.title'),
description: t('onboarding.admin.accountPriority.description'),
side: 'top',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-groups"]',
popover: {
title: t('onboarding.admin.accountGroups.title'),
description: t('onboarding.admin.accountGroups.description'),
side: 'top',
align: 'center',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-submit"]',
popover: {
title: t('onboarding.admin.accountSubmit.title'),
description: t('onboarding.admin.accountSubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
},
// ========== 第三部分创建API密钥 ==========
{
element: '[data-tour="sidebar-my-keys"]',
popover: {
title: t('onboarding.admin.keyManage.title'),
description: t('onboarding.admin.keyManage.description'),
side: 'right',
align: 'center',
showButtons: ['close']
}
},
{
element: '[data-tour="keys-create-btn"]',
popover: {
title: t('onboarding.admin.createKey.title'),
description: t('onboarding.admin.createKey.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="key-form-name"]',
popover: {
title: t('onboarding.admin.keyName.title'),
description: t('onboarding.admin.keyName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-group"]',
popover: {
title: t('onboarding.admin.keyGroup.title'),
description: t('onboarding.admin.keyGroup.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-submit"]',
popover: {
title: t('onboarding.admin.keySubmit.title'),
description: t('onboarding.admin.keySubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
}
]
// 简易模式下过滤分组相关步骤
if (isSimpleMode) {
return allSteps.filter(step => {
const element = step.element as string | undefined
// 过滤掉分组管理和账号分组选择相关步骤
return !element || (
!element.includes('sidebar-group-manage') &&
!element.includes('groups-create-btn') &&
!element.includes('group-form-') &&
!element.includes('account-form-groups')
)
})
}
return allSteps
}
/**
* 普通用户引导流程
*/
export const getUserSteps = (t: (key: string) => string): DriveStep[] => [
{
popover: {
title: t('onboarding.user.welcome.title'),
description: t('onboarding.user.welcome.description'),
align: 'center',
nextBtnText: t('onboarding.user.welcome.nextBtn'),
prevBtnText: t('onboarding.user.welcome.prevBtn')
}
},
{
element: '[data-tour="sidebar-my-keys"]',
popover: {
title: t('onboarding.user.keyManage.title'),
description: t('onboarding.user.keyManage.description'),
side: 'right',
align: 'center',
showButtons: ['close']
}
},
{
element: '[data-tour="keys-create-btn"]',
popover: {
title: t('onboarding.user.createKey.title'),
description: t('onboarding.user.createKey.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="key-form-name"]',
popover: {
title: t('onboarding.user.keyName.title'),
description: t('onboarding.user.keyName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-group"]',
popover: {
title: t('onboarding.user.keyGroup.title'),
description: t('onboarding.user.keyGroup.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-submit"]',
popover: {
title: t('onboarding.user.keySubmit.title'),
description: t('onboarding.user.keySubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
}
]

View File

@@ -0,0 +1,182 @@
<template>
<div v-if="siteKey" class="turnstile-wrapper">
<div ref="containerRef" class="turnstile-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
interface TurnstileRenderOptions {
sitekey: string
callback: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
}
interface TurnstileAPI {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
reset: (widgetId?: string) => void
remove: (widgetId?: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileAPI
onTurnstileLoad?: () => void
}
}
const props = withDefaults(
defineProps<{
siteKey: string
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
}>(),
{
theme: 'auto',
size: 'flexible'
}
)
const emit = defineEmits<{
(e: 'verify', token: string): void
(e: 'expire'): void
(e: 'error'): void
}>()
const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<string | null>(null)
const scriptLoaded = ref(false)
const loadScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (window.turnstile) {
scriptLoaded.value = true
resolve()
return
}
// Check if script is already loading
const existingScript = document.querySelector('script[src*="turnstile"]')
if (existingScript) {
window.onTurnstileLoad = () => {
scriptLoaded.value = true
resolve()
}
return
}
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad'
script.async = true
script.defer = true
window.onTurnstileLoad = () => {
scriptLoaded.value = true
resolve()
}
script.onerror = () => {
reject(new Error('Failed to load Turnstile script'))
}
document.head.appendChild(script)
})
}
const renderWidget = () => {
if (!window.turnstile || !containerRef.value || !props.siteKey) {
return
}
// Remove existing widget if any
if (widgetId.value) {
try {
window.turnstile.remove(widgetId.value)
} catch {
// Ignore errors when removing
}
widgetId.value = null
}
// Clear container
containerRef.value.innerHTML = ''
widgetId.value = window.turnstile.render(containerRef.value, {
sitekey: props.siteKey,
callback: (token: string) => {
emit('verify', token)
},
'expired-callback': () => {
emit('expire')
},
'error-callback': () => {
emit('error')
},
theme: props.theme,
size: props.size
})
}
const reset = () => {
if (window.turnstile && widgetId.value) {
window.turnstile.reset(widgetId.value)
}
}
// Expose reset method to parent
defineExpose({ reset })
onMounted(async () => {
if (!props.siteKey) {
return
}
try {
await loadScript()
renderWidget()
} catch (error) {
console.error('Failed to initialize Turnstile:', error)
emit('error')
}
})
onUnmounted(() => {
if (window.turnstile && widgetId.value) {
try {
window.turnstile.remove(widgetId.value)
} catch {
// Ignore errors when removing
}
}
})
// Re-render when siteKey changes
watch(
() => props.siteKey,
(newKey) => {
if (newKey && scriptLoaded.value) {
renderWidget()
}
}
)
</script>
<style scoped>
.turnstile-wrapper {
width: 100%;
}
.turnstile-container {
width: 100%;
min-height: 65px;
}
/* Make the Turnstile iframe fill the container width */
.turnstile-container :deep(iframe) {
width: 100% !important;
}
</style>

View File

@@ -0,0 +1,184 @@
/**
* API Key 创建逻辑测试
* 通过封装组件测试 API Key 创建的核心流程
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { defineComponent, ref, reactive } from 'vue'
// Mock keysAPI
const mockCreate = vi.fn()
const mockList = vi.fn()
vi.mock('@/api', () => ({
keysAPI: {
create: (...args: any[]) => mockCreate(...args),
list: (...args: any[]) => mockList(...args),
},
authAPI: {
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
logout: vi.fn(),
refreshToken: vi.fn(),
},
isTotp2FARequired: () => false,
}))
vi.mock('@/api/admin/system', () => ({
checkUpdates: vi.fn(),
}))
vi.mock('@/api/auth', () => ({
getPublicSettings: vi.fn().mockResolvedValue({}),
}))
// Mock app store - 使用固定引用确保组件和测试共享同一对象
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
}),
}))
import { useAppStore } from '@/stores/app'
/**
* 简化的 API Key 创建测试组件
*/
const ApiKeyCreateTestComponent = defineComponent({
setup() {
const appStore = useAppStore()
const loading = ref(false)
const createdKey = ref('')
const formData = reactive({
name: '',
group_id: null as number | null,
})
const handleCreate = async () => {
if (!formData.name) return
loading.value = true
try {
const result = await mockCreate({
name: formData.name,
group_id: formData.group_id,
})
createdKey.value = result.key
appStore.showSuccess('API Key 创建成功')
} catch (error: any) {
appStore.showError(error.message || '创建失败')
} finally {
loading.value = false
}
}
return { formData, loading, createdKey, handleCreate }
},
template: `
<div>
<form @submit.prevent="handleCreate">
<input id="name" v-model="formData.name" placeholder="Key 名称" />
<select id="group" v-model="formData.group_id">
<option :value="null">默认</option>
<option :value="1">Group 1</option>
</select>
<button type="submit" :disabled="loading">创建</button>
</form>
<div v-if="createdKey" class="created-key">{{ createdKey }}</div>
</div>
`,
})
describe('ApiKey 创建流程', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('创建 API Key 调用 API 并显示结果', async () => {
mockCreate.mockResolvedValue({
id: 1,
key: 'sk-test-key-12345',
name: 'My Test Key',
})
const wrapper = mount(ApiKeyCreateTestComponent)
await wrapper.find('#name').setValue('My Test Key')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockCreate).toHaveBeenCalledWith({
name: 'My Test Key',
group_id: null,
})
expect(wrapper.find('.created-key').text()).toBe('sk-test-key-12345')
})
it('选择分组后正确传参', async () => {
mockCreate.mockResolvedValue({
id: 2,
key: 'sk-group-key',
name: 'Group Key',
})
const wrapper = mount(ApiKeyCreateTestComponent)
await wrapper.find('#name').setValue('Group Key')
// 选择 group_id = 1
await wrapper.find('#group').setValue('1')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockCreate).toHaveBeenCalledWith({
name: 'Group Key',
group_id: 1,
})
})
it('创建失败时显示错误', async () => {
mockCreate.mockRejectedValue(new Error('配额不足'))
const wrapper = mount(ApiKeyCreateTestComponent)
await wrapper.find('#name').setValue('Fail Key')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockShowError).toHaveBeenCalledWith('配额不足')
expect(wrapper.find('.created-key').exists()).toBe(false)
})
it('名称为空时不提交', async () => {
const wrapper = mount(ApiKeyCreateTestComponent)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockCreate).not.toHaveBeenCalled()
})
it('创建过程中按钮被禁用', async () => {
let resolveCreate: (v: any) => void
mockCreate.mockImplementation(
() => new Promise((resolve) => { resolveCreate = resolve })
)
const wrapper = mount(ApiKeyCreateTestComponent)
await wrapper.find('#name').setValue('Test Key')
await wrapper.find('form').trigger('submit')
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
resolveCreate!({ id: 1, key: 'sk-test', name: 'Test Key' })
await flushPromises()
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
})
})

View File

@@ -0,0 +1,172 @@
/**
* Dashboard 数据加载逻辑测试
* 通过封装组件测试仪表板核心数据加载流程
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { defineComponent, ref, onMounted, nextTick } from 'vue'
// Mock API
const mockGetDashboardStats = vi.fn()
vi.mock('@/api', () => ({
authAPI: {
getCurrentUser: vi.fn().mockResolvedValue({
data: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 100, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
}),
logout: vi.fn(),
refreshToken: vi.fn(),
},
isTotp2FARequired: () => false,
}))
vi.mock('@/api/usage', () => ({
usageAPI: {
getDashboardStats: (...args: any[]) => mockGetDashboardStats(...args),
},
}))
vi.mock('@/api/admin/system', () => ({
checkUpdates: vi.fn(),
}))
vi.mock('@/api/auth', () => ({
getPublicSettings: vi.fn().mockResolvedValue({}),
}))
interface DashboardStats {
balance: number
api_key_count: number
active_api_key_count: number
today_requests: number
today_cost: number
today_tokens: number
total_tokens: number
}
/**
* 简化的 Dashboard 测试组件
*/
const DashboardTestComponent = defineComponent({
setup() {
const stats = ref<DashboardStats | null>(null)
const loading = ref(false)
const error = ref('')
const loadStats = async () => {
loading.value = true
error.value = ''
try {
stats.value = await mockGetDashboardStats()
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
onMounted(loadStats)
return { stats, loading, error, loadStats }
},
template: `
<div>
<div v-if="loading" class="loading">加载中...</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="stats" class="stats">
<span class="balance">{{ stats.balance }}</span>
<span class="api-keys">{{ stats.api_key_count }}</span>
<span class="today-requests">{{ stats.today_requests }}</span>
<span class="today-cost">{{ stats.today_cost }}</span>
</div>
<button class="refresh" @click="loadStats">刷新</button>
</div>
`,
})
describe('Dashboard 数据加载', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
const fakeStats: DashboardStats = {
balance: 100.5,
api_key_count: 3,
active_api_key_count: 2,
today_requests: 150,
today_cost: 2.5,
today_tokens: 50000,
total_tokens: 1000000,
}
it('挂载后自动加载数据', async () => {
mockGetDashboardStats.mockResolvedValue(fakeStats)
const wrapper = mount(DashboardTestComponent)
await flushPromises()
expect(mockGetDashboardStats).toHaveBeenCalledTimes(1)
expect(wrapper.find('.balance').text()).toBe('100.5')
expect(wrapper.find('.api-keys').text()).toBe('3')
expect(wrapper.find('.today-requests').text()).toBe('150')
expect(wrapper.find('.today-cost').text()).toBe('2.5')
})
it('加载中显示 loading 状态', async () => {
let resolveStats: (v: any) => void
mockGetDashboardStats.mockImplementation(
() => new Promise((resolve) => { resolveStats = resolve })
)
const wrapper = mount(DashboardTestComponent)
await nextTick()
expect(wrapper.find('.loading').exists()).toBe(true)
resolveStats!(fakeStats)
await flushPromises()
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.find('.stats').exists()).toBe(true)
})
it('加载失败时显示错误信息', async () => {
mockGetDashboardStats.mockRejectedValue(new Error('Network error'))
const wrapper = mount(DashboardTestComponent)
await flushPromises()
expect(wrapper.find('.error').text()).toBe('Network error')
expect(wrapper.find('.stats').exists()).toBe(false)
})
it('点击刷新按钮重新加载数据', async () => {
mockGetDashboardStats.mockResolvedValue(fakeStats)
const wrapper = mount(DashboardTestComponent)
await flushPromises()
expect(mockGetDashboardStats).toHaveBeenCalledTimes(1)
// 更新数据
const updatedStats = { ...fakeStats, today_requests: 200 }
mockGetDashboardStats.mockResolvedValue(updatedStats)
await wrapper.find('.refresh').trigger('click')
await flushPromises()
expect(mockGetDashboardStats).toHaveBeenCalledTimes(2)
expect(wrapper.find('.today-requests').text()).toBe('200')
})
it('数据为空时不显示统计信息', async () => {
mockGetDashboardStats.mockResolvedValue(null)
const wrapper = mount(DashboardTestComponent)
await flushPromises()
expect(wrapper.find('.stats').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,178 @@
/**
* LoginView 组件核心逻辑测试
* 测试登录表单提交、验证、2FA 等场景
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { defineComponent, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
// Mock 所有外部依赖
const mockLogin = vi.fn()
const mockLogin2FA = vi.fn()
const mockPush = vi.fn()
vi.mock('@/api', () => ({
authAPI: {
login: (...args: any[]) => mockLogin(...args),
login2FA: (...args: any[]) => mockLogin2FA(...args),
logout: vi.fn(),
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
register: vi.fn(),
refreshToken: vi.fn(),
},
isTotp2FARequired: (response: any) => response?.requires_2fa === true,
}))
vi.mock('@/api/admin/system', () => ({
checkUpdates: vi.fn(),
}))
vi.mock('@/api/auth', () => ({
getPublicSettings: vi.fn().mockResolvedValue({}),
}))
/**
* 创建一个简化的测试组件来封装登录逻辑
* 避免引入 LoginView.vue 的全部依赖AuthLayout、i18n、Icon 等)
*/
const LoginFormTestComponent = defineComponent({
setup() {
const authStore = useAuthStore()
const formData = reactive({ email: '', password: '' })
const isLoading = ref(false)
const errorMessage = ref('')
const handleLogin = async () => {
if (!formData.email || !formData.password) {
errorMessage.value = '请输入邮箱和密码'
return
}
isLoading.value = true
errorMessage.value = ''
try {
const response = await authStore.login({
email: formData.email,
password: formData.password,
})
// 2FA 流程由调用方处理
if ((response as any)?.requires_2fa) {
errorMessage.value = '需要 2FA 验证'
return
}
mockPush('/dashboard')
} catch (error: any) {
errorMessage.value = error.message || '登录失败'
} finally {
isLoading.value = false
}
}
return { formData, isLoading, errorMessage, handleLogin }
},
template: `
<form @submit.prevent="handleLogin">
<input id="email" v-model="formData.email" type="email" />
<input id="password" v-model="formData.password" type="password" />
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<button type="submit" :disabled="isLoading">登录</button>
</form>
`,
})
describe('LoginForm 核心逻辑', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('成功登录后跳转到 dashboard', async () => {
mockLogin.mockResolvedValue({
access_token: 'token',
token_type: 'Bearer',
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 0, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
})
const wrapper = mount(LoginFormTestComponent)
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockLogin).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
expect(mockPush).toHaveBeenCalledWith('/dashboard')
})
it('登录失败时显示错误信息', async () => {
mockLogin.mockRejectedValue(new Error('Invalid credentials'))
const wrapper = mount(LoginFormTestComponent)
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('.error').text()).toBe('Invalid credentials')
})
it('空表单提交显示验证错误', async () => {
const wrapper = mount(LoginFormTestComponent)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('.error').text()).toBe('请输入邮箱和密码')
expect(mockLogin).not.toHaveBeenCalled()
})
it('需要 2FA 时不跳转', async () => {
mockLogin.mockResolvedValue({
requires_2fa: true,
temp_token: 'temp-123',
})
const wrapper = mount(LoginFormTestComponent)
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockPush).not.toHaveBeenCalled()
expect(wrapper.find('.error').text()).toBe('需要 2FA 验证')
})
it('提交过程中按钮被禁用', async () => {
let resolveLogin: (v: any) => void
mockLogin.mockImplementation(
() => new Promise((resolve) => { resolveLogin = resolve })
)
const wrapper = mount(LoginFormTestComponent)
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('form').trigger('submit')
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
resolveLogin!({
access_token: 'token',
token_type: 'Bearer',
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 0, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
})
await flushPromises()
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
})
})

View File

@@ -0,0 +1,315 @@
<template>
<div class="flex flex-col gap-1.5">
<!-- 并发槽位 -->
<div class="flex items-center gap-1.5">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
concurrencyClass
]"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
<span class="font-mono">{{ currentConcurrency }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.concurrency }}</span>
</span>
</div>
<!-- 5h窗口费用限制 Anthropic OAuth/SetupToken 且启用时显示 -->
<div v-if="showWindowCost" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
windowCostClass
]"
:title="windowCostTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-mono">${{ formatCost(currentWindowCost) }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">${{ formatCost(account.window_cost_limit) }}</span>
</span>
</div>
<!-- 会话数量限制 Anthropic OAuth/SetupToken 且启用时显示 -->
<div v-if="showSessionLimit" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
sessionLimitClass
]"
:title="sessionLimitTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span class="font-mono">{{ activeSessions }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.max_sessions }}</span>
</span>
</div>
<!-- RPM 限制 Anthropic OAuth/SetupToken 且启用时显示 -->
<div v-if="showRpmLimit" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
rpmClass
]"
:title="rpmTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="font-mono">{{ currentRPM }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.base_rpm }}</span>
<span class="text-[9px] opacity-60">{{ rpmStrategyTag }}</span>
</span>
</div>
<!-- API Key 账号配额限制 -->
<QuotaBadge v-if="showDailyQuota" :used="account.quota_daily_used ?? 0" :limit="account.quota_daily_limit!" label="D" />
<QuotaBadge v-if="showWeeklyQuota" :used="account.quota_weekly_used ?? 0" :limit="account.quota_weekly_limit!" label="W" />
<QuotaBadge v-if="showTotalQuota" :used="account.quota_used ?? 0" :limit="account.quota_limit!" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import QuotaBadge from './QuotaBadge.vue'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
// 当前并发数
const currentConcurrency = computed(() => props.account.current_concurrency || 0)
// 是否为 Anthropic OAuth/SetupToken 账号
const isAnthropicOAuthOrSetupToken = computed(() => {
return (
props.account.platform === 'anthropic' &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
)
})
// 是否显示窗口费用限制
const showWindowCost = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.window_cost_limit !== undefined &&
props.account.window_cost_limit !== null &&
props.account.window_cost_limit > 0
)
})
// 当前窗口费用
const currentWindowCost = computed(() => props.account.current_window_cost ?? 0)
// 是否显示会话限制
const showSessionLimit = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.max_sessions !== undefined &&
props.account.max_sessions !== null &&
props.account.max_sessions > 0
)
})
// 当前活跃会话数
const activeSessions = computed(() => props.account.active_sessions ?? 0)
// 并发状态样式
const concurrencyClass = computed(() => {
const current = currentConcurrency.value
const max = props.account.concurrency
if (current >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current > 0) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
})
// 窗口费用状态样式
const windowCostClass = computed(() => {
if (!showWindowCost.value) return ''
const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10
if (current >= limit + reserve) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current >= limit) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
if (current >= limit * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// 窗口费用提示文字
const windowCostTooltip = computed(() => {
if (!showWindowCost.value) return ''
const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10
if (current >= limit + reserve) {
return t('admin.accounts.capacity.windowCost.blocked')
}
if (current >= limit) {
return t('admin.accounts.capacity.windowCost.stickyOnly')
}
return t('admin.accounts.capacity.windowCost.normal')
})
// 会话限制状态样式
const sessionLimitClass = computed(() => {
if (!showSessionLimit.value) return ''
const current = activeSessions.value
const max = props.account.max_sessions || 0
if (current >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current >= max * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// 会话限制提示文字
const sessionLimitTooltip = computed(() => {
if (!showSessionLimit.value) return ''
const current = activeSessions.value
const max = props.account.max_sessions || 0
const idle = props.account.session_idle_timeout_minutes || 5
if (current >= max) {
return t('admin.accounts.capacity.sessions.full', { idle })
}
return t('admin.accounts.capacity.sessions.normal', { idle })
})
// 是否显示 RPM 限制
const showRpmLimit = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.base_rpm !== undefined &&
props.account.base_rpm !== null &&
props.account.base_rpm > 0
)
})
// 当前 RPM 计数
const currentRPM = computed(() => props.account.current_rpm ?? 0)
// RPM 策略
const rpmStrategy = computed(() => props.account.rpm_strategy || 'tiered')
// RPM 策略标签
const rpmStrategyTag = computed(() => {
return rpmStrategy.value === 'sticky_exempt' ? '[S]' : '[T]'
})
// RPM buffer 计算与后端一致base <= 0 时 buffer 为 0
const rpmBuffer = computed(() => {
const base = props.account.base_rpm || 0
return props.account.rpm_sticky_buffer ?? (base > 0 ? Math.max(1, Math.floor(base / 5)) : 0)
})
// RPM 状态样式
const rpmClass = computed(() => {
if (!showRpmLimit.value) return ''
const current = currentRPM.value
const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current >= base) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
} else {
if (current >= base) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
}
if (current >= base * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// RPM 提示文字(增强版:显示策略、区域、缓冲区)
const rpmTooltip = computed(() => {
if (!showRpmLimit.value) return ''
const current = currentRPM.value
const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) {
return t('admin.accounts.capacity.rpm.tieredBlocked', { buffer })
}
if (current >= base) {
return t('admin.accounts.capacity.rpm.tieredStickyOnly', { buffer })
}
if (current >= base * 0.8) {
return t('admin.accounts.capacity.rpm.tieredWarning')
}
return t('admin.accounts.capacity.rpm.tieredNormal')
} else {
if (current >= base) {
return t('admin.accounts.capacity.rpm.stickyExemptOver')
}
if (current >= base * 0.8) {
return t('admin.accounts.capacity.rpm.stickyExemptWarning')
}
return t('admin.accounts.capacity.rpm.stickyExemptNormal')
}
})
// 是否显示各维度配额apikey / bedrock 类型)
const isQuotaEligible = computed(() => props.account.type === 'apikey' || props.account.type === 'bedrock')
const showDailyQuota = computed(() => {
return isQuotaEligible.value && (props.account.quota_daily_limit ?? 0) > 0
})
const showWeeklyQuota = computed(() => {
return isQuotaEligible.value && (props.account.quota_weekly_limit ?? 0) > 0
})
const showTotalQuota = computed(() => {
return isQuotaEligible.value && (props.account.quota_limit ?? 0) > 0
})
// 格式化费用显示
const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0'
return value.toFixed(2)
}
</script>

View File

@@ -0,0 +1,158 @@
<template>
<div v-if="groups && groups.length > 0" class="relative max-w-56">
<!-- 分组容器固定最大宽度最多显示2行 -->
<div class="flex flex-wrap gap-1 max-h-14 overflow-hidden">
<GroupBadge
v-for="group in displayGroups"
:key="group.id"
:name="group.name"
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
:show-rate="false"
class="max-w-24"
/>
<!-- 更多数量徽章 -->
<button
v-if="hiddenCount > 0"
ref="moreButtonRef"
@click.stop="showPopover = !showPopover"
class="inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500 transition-colors cursor-pointer whitespace-nowrap"
>
<span>+{{ hiddenCount }}</span>
</button>
</div>
<!-- Popover 显示完整列表 -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showPopover"
ref="popoverRef"
class="fixed z-50 min-w-48 max-w-96 rounded-lg border border-gray-200 bg-white p-3 shadow-lg dark:border-dark-600 dark:bg-dark-800"
:style="popoverStyle"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.groupCountTotal', { count: groups.length }) }}
</span>
<button
@click="showPopover = false"
class="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-300"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex flex-wrap gap-1.5 max-h-64 overflow-y-auto">
<GroupBadge
v-for="group in groups"
:key="group.id"
:name="group.name"
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
:show-rate="false"
/>
</div>
</div>
</Transition>
</Teleport>
<!-- 点击外部关闭 popover -->
<div
v-if="showPopover"
class="fixed inset-0 z-40"
@click="showPopover = false"
/>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from '@/components/common/GroupBadge.vue'
import type { Group } from '@/types'
interface Props {
groups: Group[] | null | undefined
maxDisplay?: number
}
const props = withDefaults(defineProps<Props>(), {
maxDisplay: 4
})
const { t } = useI18n()
const moreButtonRef = ref<HTMLElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
const showPopover = ref(false)
// 显示的分组(最多显示 maxDisplay 个)
const displayGroups = computed(() => {
if (!props.groups) return []
if (props.groups.length <= props.maxDisplay) {
return props.groups
}
// 留一个位置给 +N 按钮
return props.groups.slice(0, props.maxDisplay - 1)
})
// 隐藏的数量
const hiddenCount = computed(() => {
if (!props.groups) return 0
if (props.groups.length <= props.maxDisplay) return 0
return props.groups.length - (props.maxDisplay - 1)
})
// Popover 位置样式
const popoverStyle = computed(() => {
if (!moreButtonRef.value) return {}
const rect = moreButtonRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
let top = rect.bottom + 8
let left = rect.left
// 如果下方空间不足,显示在上方
if (top + 280 > viewportHeight) {
top = Math.max(8, rect.top - 280)
}
// 如果右侧空间不足,向左偏移
if (left + 384 > viewportWidth) {
left = Math.max(8, viewportWidth - 392)
}
return {
top: `${top}px`,
left: `${left}px`
}
})
// 关闭 popover 的键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
showPopover.value = false
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>

View File

@@ -0,0 +1,198 @@
<template>
<div v-if="shouldShowQuota">
<!-- First line: Platform + Tier Badge -->
<div class="mb-1 flex items-center gap-1">
<span :class="['badge text-xs px-2 py-0.5 rounded font-medium', tierBadgeClass]">
{{ tierLabel }}
</span>
</div>
<!-- Usage status: unlimited flow or rate limit -->
<div class="text-xs text-gray-400 dark:text-gray-500">
<span v-if="!isRateLimited">
{{ t('admin.accounts.gemini.rateLimit.unlimited') }}
</span>
<span
v-else
:class="[
'font-medium',
isUrgent
? 'text-red-600 dark:text-red-400 animate-pulse'
: 'text-amber-600 dark:text-amber-400'
]"
>
{{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account, GeminiCredentials } from '@/types'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
const now = ref(new Date())
let timer: ReturnType<typeof setInterval> | null = null
// 是否为 Code Assist OAuth
// 判断逻辑与后端保持一致project_id 存在即为 Code Assist
const isCodeAssist = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
// 显式为 code_assist或 legacy 情况oauth_type 为空但 project_id 存在)
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
})
// 是否为 Google One OAuth
const isGoogleOne = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
return creds?.oauth_type === 'google_one'
})
// 是否应该显示配额信息
const shouldShowQuota = computed(() => {
return props.account.platform === 'gemini'
})
// Tier 标签文本
const tierLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) {
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'gcp_enterprise') return 'GCP Enterprise'
if (tier === 'gcp_standard') return 'GCP Standard'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'GCP Enterprise'
if (upper) return `GCP ${upper}`
return 'GCP'
}
if (isGoogleOne.value) {
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'google_ai_ultra') return 'Google AI Ultra'
if (tier === 'google_ai_pro') return 'Google AI Pro'
if (tier === 'google_one_free') return 'Google One Free'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper === 'AI_PREMIUM') return 'Google AI Pro'
if (upper === 'GOOGLE_ONE_UNLIMITED') return 'Google AI Ultra'
if (upper) return `Google One ${upper}`
return 'Google One'
}
// API Key: 显示 AI Studio
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'aistudio_paid') return 'AI Studio Pay-as-you-go'
if (tier === 'aistudio_free') return 'AI Studio Free Tier'
return 'AI Studio'
})
// Tier Badge 样式(统一样式)
const tierBadgeClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) {
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'gcp_enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
if (tier === 'gcp_standard') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
}
if (isGoogleOne.value) {
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'google_ai_ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
if (tier === 'google_ai_pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
if (tier === 'google_one_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper === 'GOOGLE_ONE_UNLIMITED') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
if (upper === 'AI_PREMIUM') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
// AI Studio 默认样式:蓝色
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'aistudio_paid') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
if (tier === 'aistudio_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
})
// 是否限流
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护如果日期解析失败NaN则认为未限流
if (Number.isNaN(resetTime)) return false
return resetTime > now.value.getTime()
})
// 倒计时文本
const resetCountdown = computed(() => {
if (!props.account.rate_limit_reset_at) return ''
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护:如果日期解析失败,显示 "-"
if (Number.isNaN(resetTime)) return '-'
const diffMs = resetTime - now.value.getTime()
if (diffMs <= 0) return t('admin.accounts.gemini.rateLimit.now')
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
if (diffMinutes < 1) return `${diffSeconds}s`
if (diffHours < 1) {
const secs = diffSeconds % 60
return `${diffMinutes}m ${secs}s`
}
const mins = diffMinutes % 60
return `${diffHours}h ${mins}m`
})
// 是否紧急(< 1分钟
const isUrgent = computed(() => {
if (!props.account.rate_limit_reset_at) return false
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护:如果日期解析失败,返回 false
if (Number.isNaN(resetTime)) return false
const diffMs = resetTime - now.value.getTime()
return diffMs > 0 && diffMs < 60000
})
// 监听限流状态,动态启动/停止定时器
watch(
() => isRateLimited.value,
(limited) => {
if (limited && !timer) {
// 进入限流状态,启动定时器
timer = setInterval(() => {
now.value = new Date()
}, 1000)
} else if (!limited && timer) {
// 解除限流,停止定时器
clearInterval(timer)
timer = null
}
},
{ immediate: true } // 立即执行,确保挂载时已限流的情况也能启动定时器
)
onUnmounted(() => {
if (timer !== null) {
clearInterval(timer)
timer = null
}
})
</script>

View File

@@ -0,0 +1,749 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.usageStatistics')"
width="extra-wide"
@close="handleClose"
>
<div class="space-y-6">
<!-- Account Info Header -->
<div
v-if="account"
class="flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<Icon name="chartBar" size="md" class="text-white" :stroke-width="2" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.last30DaysUsage') }}
</div>
</div>
</div>
<span
:class="[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<template v-else-if="stats">
<!-- Row 1: Main Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- 30-Day Total Cost -->
<div
class="card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalCost')
}}</span>
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
<svg
class="h-4 w-4 text-emerald-600 dark:text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.total_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost)
}})
</span>
</p>
</div>
<!-- 30-Day Total Requests -->
<div
class="card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalRequests')
}}</span>
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
<Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ formatNumber(stats.summary.total_requests) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.totalCalls') }}
</p>
</div>
<!-- Daily Average Cost -->
<div
class="card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgDailyCost')
}}</span>
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
<Icon
name="calculator"
size="sm"
class="text-amber-600 dark:text-amber-400"
:stroke-width="2"
/>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used
})
}}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p>
</div>
<!-- Daily Average Requests -->
<div
class="card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgDailyRequests')
}}</span>
<div class="rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30">
<svg
class="h-4 w-4 text-purple-600 dark:text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.avgDailyUsage') }}
</p>
</div>
</div>
<!-- Row 2: Today, Highest Cost, Highest Requests -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- Today Overview -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
<svg
class="h-4 w-4 text-cyan-600 dark:text-cyan-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.todayOverview')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.today?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.tokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0)
}}</span>
</div>
</div>
</div>
<!-- Highest Cost Day -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
<Icon
name="fire"
size="sm"
class="text-orange-600 dark:text-orange-400"
:stroke-width="2"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestCostDay')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.date')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
stats.summary.highest_cost_day?.label || '-'
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.highest_cost_day?.requests || 0)
}}</span>
</div>
</div>
</div>
<!-- Highest Request Day -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
<Icon
name="trendingUp"
size="sm"
class="text-indigo-600 dark:text-indigo-400"
:stroke-width="2"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestRequestDay')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.date')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
stats.summary.highest_request_day?.label || '-'
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{
formatNumber(stats.summary.highest_request_day?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div>
</div>
</div>
<!-- Row 3: Token Stats -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- Accumulated Tokens -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
<Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" :stroke-width="2" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.accumulatedTokens')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.total_tokens)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.dailyAvgTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(Math.round(stats.summary.avg_daily_tokens))
}}</span>
</div>
</div>
</div>
<!-- Performance -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
<Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.performance')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgResponseTime')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatDuration(stats.summary.avg_duration_ms)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.daysActive')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span
>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
<Icon
name="clipboard"
size="sm"
class="text-lime-600 dark:text-lime-400"
:stroke-width="2"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.recentActivity')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayRequests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.today?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
</div>
</div>
</div>
<!-- Usage Trend Chart -->
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.stats.usageTrend') }}
</h3>
<div class="h-64">
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</div>
<!-- Model Distribution -->
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
<EndpointDistributionChart
:endpoint-stats="stats.endpoints || []"
:loading="false"
:title="t('usage.inboundEndpoint')"
/>
<EndpointDistributionChart
:endpoint-stats="stats.upstream_endpoints || []"
:loading="false"
:title="t('usage.upstreamEndpoint')"
/>
</template>
<!-- No Data State -->
<div
v-else-if="!loading"
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
>
<Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" :stroke-width="1.5" />
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const { t } = useI18n()
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const loading = ref(false)
const stats = ref<AccountUsageStatsResponse | null>(null)
// Dark mode detection
const isDarkMode = computed(() => {
return document.documentElement.classList.contains('dark')
})
// Chart colors
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
}))
// Line chart data
const trendChartData = computed(() => {
if (!stats.value?.history?.length) return null
return {
labels: stats.value.history.map((h) => h.label),
datasets: [
{
label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{
label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests),
borderColor: '#f97316',
backgroundColor: 'rgba(249, 115, 22, 0.1)',
fill: false,
tension: 0.3,
yAxisID: 'y1'
}
]
}
})
// Line chart options with dual Y-axis
const lineChartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const
},
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.value.text,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 11
}
}
},
tooltip: {
callbacks: {
label: (context: any) => {
const label = context.dataset.label || ''
const value = context.raw
if (label.includes('USD')) {
return `${label}: $${formatCost(value)}`
}
return `${label}: ${formatNumber(value)}`
}
}
}
},
scales: {
x: {
grid: {
color: chartColors.value.grid
},
ticks: {
color: chartColors.value.text,
font: {
size: 10
},
maxRotation: 45,
minRotation: 0
}
},
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
grid: {
color: chartColors.value.grid
},
ticks: {
color: '#3b82f6',
font: {
size: 10
},
callback: (value: string | number) => '$' + formatCost(Number(value))
},
title: {
display: true,
text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6',
font: {
size: 11
}
}
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
grid: {
drawOnChartArea: false
},
ticks: {
color: '#f97316',
font: {
size: 10
},
callback: (value: string | number) => formatNumber(Number(value))
},
title: {
display: true,
text: t('admin.accounts.stats.requests'),
color: '#f97316',
font: {
size: 11
}
}
}
}
}))
// Load stats when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
await loadStats()
} else {
stats.value = null
}
}
)
const loadStats = async () => {
if (!props.account) return
loading.value = true
try {
stats.value = await adminAPI.accounts.getStats(props.account.id, 30)
} catch (error) {
console.error('Failed to load account stats:', error)
stats.value = null
} finally {
loading.value = false
}
}
const handleClose = () => {
emit('close')
}
// Format helpers
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
const formatNumber = (value: number): string => {
if (value >= 1_000_000) {
return (value / 1_000_000).toFixed(2) + 'M'
} else if (value >= 1_000) {
return (value / 1_000).toFixed(2) + 'K'
}
return value.toLocaleString()
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatDuration = (ms: number): string => {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(2)}s`
}
return `${Math.round(ms)}ms`
}
</script>

View File

@@ -0,0 +1,343 @@
<template>
<div class="flex items-center gap-2">
<!-- Rate Limit Display (429) - Two-line layout -->
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitResumeText }}</span>
</div>
<!-- Overload Display (529) - Two-line layout -->
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
</div>
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
<template v-else>
<button
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<span v-else :class="['badge text-xs', statusClass]">
{{ statusText }}
</span>
</template>
<!-- Error Info Indicator -->
<div v-if="hasError && account.error_message" class="group/error relative">
<svg
class="h-4 w-4 cursor-help text-red-500 transition-colors hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
<!-- Tooltip - 向下显示 -->
<div
class="invisible absolute left-0 top-full z-[100] mt-1.5 min-w-[200px] max-w-[300px] rounded-lg bg-gray-800 px-3 py-2 text-xs text-white opacity-0 shadow-xl transition-all duration-200 group-hover/error:visible group-hover/error:opacity-100 dark:bg-gray-900"
>
<div class="whitespace-pre-wrap break-words leading-relaxed text-gray-300">
{{ account.error_message }}
</div>
<!-- 上方小三角 -->
<div
class="absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="group relative">
<span
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
429
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatDateTime(account.rate_limit_reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
<!-- Model Status Indicators (普通限流 / 超量请求中) -->
<div
v-if="activeModelStatuses.length > 0"
:class="[
activeModelStatuses.length <= 4
? 'flex flex-col gap-1'
: activeModelStatuses.length <= 8
? 'columns-2 gap-x-2'
: 'columns-3 gap-x-2'
]"
>
<div v-for="item in activeModelStatuses" :key="`${item.kind}-${item.model}`" class="group relative mb-1 break-inside-avoid">
<!-- 积分已用尽 -->
<span
v-if="item.kind === 'credits_exhausted'"
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ t('admin.accounts.status.creditsExhausted') }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span>
<!-- 正在走积分模型限流但积分可用-->
<span
v-else-if="item.kind === 'credits_active'"
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<span></span>
{{ formatScopeName(item.model) }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span>
<!-- 普通模型限流 -->
<span
v-else
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ formatScopeName(item.model) }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{
item.kind === 'credits_exhausted'
? t('admin.accounts.status.creditsExhaustedUntil', { time: formatTime(item.reset_at) })
: item.kind === 'credits_active'
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
}}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
</div>
<!-- Overload Indicator (529) -->
<div v-if="isOverloaded" class="group relative">
<span
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
529
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { Account } from '@/types'
import { formatCountdown, formatDateTime, formatCountdownWithSuffix, formatTime } from '@/utils/format'
const { t } = useI18n()
const props = defineProps<{
account: Account
}>()
const emit = defineEmits<{
(e: 'show-temp-unsched', account: Account): void
}>()
// Computed: is rate limited (429)
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
return new Date(props.account.rate_limit_reset_at) > new Date()
})
type AccountModelStatusItem = {
kind: 'rate_limit' | 'credits_exhausted' | 'credits_active'
model: string
reset_at: string
}
// Computed: active model statuses (普通模型限流 + 积分耗尽 + 走积分中)
const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
const extra = props.account.extra as Record<string, unknown> | undefined
const modelLimits = extra?.model_rate_limits as
| Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
| undefined
const now = new Date()
const items: AccountModelStatusItem[] = []
if (!modelLimits) return items
// 检查 AICredits key 是否生效(积分是否耗尽)
const aiCreditsEntry = modelLimits['AICredits']
const hasActiveAICredits = aiCreditsEntry && new Date(aiCreditsEntry.rate_limit_reset_at) > now
const allowOverages = !!(extra?.allow_overages)
for (const [model, info] of Object.entries(modelLimits)) {
if (new Date(info.rate_limit_reset_at) <= now) continue
if (model === 'AICredits') {
// AICredits key → 积分已用尽
items.push({ kind: 'credits_exhausted', model, reset_at: info.rate_limit_reset_at })
} else if (allowOverages && !hasActiveAICredits) {
// 普通模型限流 + overages 启用 + 积分可用 → 正在走积分
items.push({ kind: 'credits_active', model, reset_at: info.rate_limit_reset_at })
} else {
// 普通模型限流
items.push({ kind: 'rate_limit', model, reset_at: info.rate_limit_reset_at })
}
}
return items
})
const formatScopeName = (scope: string): string => {
const aliases: Record<string, string> = {
// Claude 系列
'claude-opus-4-6': 'COpus46',
'claude-opus-4-6-thinking': 'COpus46T',
'claude-sonnet-4-6': 'CSon46',
'claude-sonnet-4-5': 'CSon45',
'claude-sonnet-4-5-thinking': 'CSon45T',
// Gemini 2.5 系列
'gemini-2.5-flash': 'G25F',
'gemini-2.5-flash-lite': 'G25FL',
'gemini-2.5-flash-thinking': 'G25FT',
'gemini-2.5-pro': 'G25P',
'gemini-2.5-flash-image': 'G25I',
// Gemini 3 系列
'gemini-3-flash': 'G3F',
'gemini-3.1-pro-high': 'G3PH',
'gemini-3.1-pro-low': 'G3PL',
'gemini-3-pro-image': 'G3PI',
'gemini-3.1-flash-image': 'G31FI',
// 其他
'gpt-oss-120b-medium': 'GPT120',
'tab_flash_lite_preview': 'TabFL',
// 旧版 scope 别名(兼容)
claude: 'Claude',
claude_sonnet: 'CSon',
claude_opus: 'COpus',
claude_haiku: 'CHaiku',
gemini_text: 'Gemini',
gemini_image: 'GImg',
gemini_flash: 'GFlash',
gemini_pro: 'GPro',
}
return aliases[scope] || scope
}
const formatModelResetTime = (resetAt: string): string => {
const date = new Date(resetAt)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
if (diffMs <= 0) return ''
const totalSecs = Math.floor(diffMs / 1000)
const h = Math.floor(totalSecs / 3600)
const m = Math.floor((totalSecs % 3600) / 60)
const s = totalSecs % 60
if (h > 0) return `${h}h${m}m`
if (m > 0) return `${m}m${s}s`
return `${s}s`
}
// Computed: is overloaded (529)
const isOverloaded = computed(() => {
if (!props.account.overload_until) return false
return new Date(props.account.overload_until) > new Date()
})
// Computed: is temp unschedulable
const isTempUnschedulable = computed(() => {
if (!props.account.temp_unschedulable_until) return false
return new Date(props.account.temp_unschedulable_until) > new Date()
})
// Computed: has error status
const hasError = computed(() => {
return props.account.status === 'error'
})
// Computed: countdown text for rate limit (429)
const rateLimitCountdown = computed(() => {
return formatCountdown(props.account.rate_limit_reset_at)
})
const rateLimitResumeText = computed(() => {
if (!rateLimitCountdown.value) return ''
return t('admin.accounts.status.rateLimitedAutoResume', { time: rateLimitCountdown.value })
})
// Computed: countdown text for overload (529)
const overloadCountdown = computed(() => {
return formatCountdownWithSuffix(props.account.overload_until)
})
// Computed: status badge class
const statusClass = computed(() => {
if (hasError.value) {
return 'badge-danger'
}
if (isTempUnschedulable.value) {
return 'badge-warning'
}
if (!props.account.schedulable) {
return 'badge-gray'
}
switch (props.account.status) {
case 'active':
return 'badge-success'
case 'inactive':
return 'badge-gray'
case 'error':
return 'badge-danger'
default:
return 'badge-gray'
}
})
// Computed: status text
const statusText = computed(() => {
if (hasError.value) {
return t('admin.accounts.status.error')
}
if (isTempUnschedulable.value) {
return t('admin.accounts.status.tempUnschedulable')
}
if (!props.account.schedulable) {
return t('admin.accounts.status.paused')
}
return t(`admin.accounts.status.${props.account.status}`)
})
const handleTempUnschedClick = () => {
if (!isTempUnschedulable.value) return
emit('show-temp-unsched', props.account)
}
</script>

View File

@@ -0,0 +1,521 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.testAccountConnection')"
width="normal"
@close="handleClose"
>
<div class="space-y-4">
<!-- Account Info Card -->
<div
v-if="account"
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<Icon name="play" size="md" class="text-white" :stroke-width="2" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{ account.type }}
</span>
<span>{{ t('admin.accounts.account') }}</span>
</div>
</div>
</div>
<span
:class="[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<div v-if="!isSoraAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<Select
v-model="selectedModelId"
:options="availableModels"
:disabled="loadingModels || status === 'connecting'"
value-key="id"
label-key="display_name"
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<div
v-else
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{ t('admin.accounts.soraTestHint') }}
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
</div>
<!-- Terminal Output -->
<div class="group relative">
<div
ref="terminalRef"
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
<Icon name="play" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
<Icon name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
<span>{{ t('admin.accounts.connectingToApi') }}</span>
</div>
<!-- Output Lines -->
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
{{ line.text }}
</div>
<!-- Streaming Content -->
<div v-if="streamingContent" class="text-green-400">
{{ streamingContent }}<span class="animate-pulse">_</span>
</div>
<!-- Result Status -->
<div
v-if="status === 'success'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<Icon name="check" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.testCompleted') }}</span>
</div>
<div
v-else-if="status === 'error'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<Icon name="x" size="sm" :stroke-width="2" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if="outputLines.length > 0"
@click="copyOutput"
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<Icon name="link" size="sm" :stroke-width="2" />
</button>
</div>
<div v-if="generatedImages.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.geminiImagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="(image, index) in generatedImages"
:key="`${image.url}-${index}`"
:href="image.url"
target="_blank"
rel="noopener noreferrer"
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
>
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<Icon name="grid" size="sm" :stroke-width="2" />
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
isSoraAccount
? t('admin.accounts.soraTestMode')
: supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
<button
@click="startTest"
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || (!isSoraAccount && !selectedModelId)
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<Icon
v-if="status === 'connecting'"
name="refresh"
size="sm"
class="animate-spin"
:stroke-width="2"
/>
<Icon v-else-if="status === 'idle'" name="play" size="sm" :stroke-width="2" />
<Icon v-else name="refresh" size="sm" :stroke-width="2" />
<span>
{{
status === 'connecting'
? t('admin.accounts.testing')
: status === 'idle'
? t('admin.accounts.startTest')
: t('admin.accounts.retry')
}}
</span>
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import TextArea from '@/components/common/TextArea.vue'
import { Icon } from '@/components/icons'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types'
const { t } = useI18n()
const { copyToClipboard } = useClipboard()
interface OutputLine {
text: string
class: string
}
interface PreviewImage {
url: string
mimeType?: string
}
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const terminalRef = ref<HTMLElement | null>(null)
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
const outputLines = ref<OutputLine[]>([])
const streamingContent = ref('')
const errorMessage = ref('')
const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
const isSoraAccount = computed(() => props.account?.platform === 'sora')
const generatedImages = ref<PreviewImage[]>([])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
if (isSoraAccount.value) return false
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
return [...models].sort((a, b) => {
const aPriority = priorityMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bPriority = priorityMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (aPriority !== bPriority) return aPriority - bPriority
return 0
})
}
// Load available models when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
testPrompt.value = ''
resetState()
await loadAvailableModels()
} else {
closeEventSource()
}
}
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
}
})
const loadAvailableModels = async () => {
if (!props.account) return
if (props.account.platform === 'sora') {
availableModels.value = []
selectedModelId.value = ''
loadingModels.value = false
return
}
loadingModels.value = true
selectedModelId.value = '' // Reset selection before loading
try {
const models = await adminAPI.accounts.getAvailableModels(props.account.id)
availableModels.value = props.account.platform === 'gemini' || props.account.platform === 'antigravity'
? sortTestModels(models)
: models
// Default selection by platform
if (availableModels.value.length > 0) {
if (props.account.platform === 'gemini') {
selectedModelId.value = availableModels.value[0].id
} else {
// Try to select Sonnet as default, otherwise use first model
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
}
}
} catch (error) {
console.error('Failed to load available models:', error)
// Fallback to empty list
availableModels.value = []
selectedModelId.value = ''
} finally {
loadingModels.value = false
}
}
const resetState = () => {
status.value = 'idle'
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
}
const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const addLine = (text: string, className: string = 'text-gray-300') => {
outputLines.value.push({ text, class: className })
scrollToBottom()
}
const scrollToBottom = async () => {
await nextTick()
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
}
const startTest = async () => {
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
try {
// Create EventSource for SSE
const url = `/api/v1/admin/accounts/${props.account.id}/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(
isSoraAccount.value
? {}
: {
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
}
)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr) {
try {
const event = JSON.parse(jsonStr)
handleEvent(event)
} catch (e) {
console.error('Failed to parse SSE event:', e)
}
}
}
}
}
} catch (error: any) {
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
}
}
const handleEvent = (event: {
type: string
text?: string
model?: string
success?: boolean
error?: string
image_url?: string
mime_type?: string
}) => {
switch (event.type) {
case 'test_start':
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
isSoraAccount.value
? t('admin.accounts.soraTestingFlow')
: supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400')
break
case 'content':
if (event.text) {
streamingContent.value += event.text
scrollToBottom()
}
break
case 'image':
if (event.image_url) {
generatedImages.value.push({
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
if (event.success) {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
break
}
}
const copyOutput = () => {
const text = outputLines.value.map((l) => l.text).join('\n')
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div>
<!-- Loading state -->
<div v-if="props.loading && !props.stats" class="space-y-0.5">
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<!-- Error state -->
<div v-else-if="props.error && !props.stats" class="text-xs text-red-500">
{{ props.error }}
</div>
<!-- Stats data -->
<div v-else-if="props.stats" class="space-y-0.5 text-xs">
<!-- Requests -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.requests') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatNumber(props.stats.requests)
}}</span>
</div>
<!-- Tokens -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.tokens') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatTokens(props.stats.tokens)
}}</span>
</div>
<!-- Cost (Account) -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}:</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(props.stats.cost)
}}</span>
</div>
<!-- Cost (User/API Key) -->
<div v-if="props.stats.user_cost != null" class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatCurrency(props.stats.user_cost)
}}</span>
</div>
</div>
<!-- No data -->
<div v-else class="text-xs text-gray-400">-</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { WindowStats } from '@/types'
import { formatNumber, formatCurrency } from '@/utils/format'
const props = withDefaults(
defineProps<{
stats?: WindowStats | null
loading?: boolean
error?: string | null
}>(),
{
stats: null,
loading: false,
error: null
}
)
const { t } = useI18n()
// Format large token numbers (e.g., 1234567 -> 1.23M)
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`
}
return tokens.toString()
}
</script>

View File

@@ -0,0 +1,236 @@
<template>
<div>
<!-- Multi-select Dropdown -->
<div class="relative mb-3">
<div
@click="toggleDropdown"
class="cursor-pointer rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-dark-500 dark:bg-dark-700"
>
<div class="grid grid-cols-2 gap-1.5">
<span
v-for="model in modelValue"
:key="model"
class="inline-flex items-center justify-between gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-300"
>
<span class="flex items-center gap-1 truncate">
<ModelIcon :model="model" size="14px" />
<span class="truncate">{{ model }}</span>
</span>
<button
type="button"
@click.stop="removeModel(model)"
class="shrink-0 rounded-full hover:bg-gray-200 dark:hover:bg-dark-500"
>
<Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
</button>
</span>
</div>
<div class="mt-2 flex items-center justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="text-xs text-gray-400">{{ t('admin.accounts.modelCount', { count: modelValue.length }) }}</span>
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<!-- Dropdown List -->
<div
v-if="showDropdown"
class="absolute left-0 right-0 top-full z-50 mt-1 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-700"
>
<div class="sticky top-0 border-b border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-700">
<input
v-model="searchQuery"
type="text"
class="input w-full text-sm"
:placeholder="t('admin.accounts.searchModels')"
@click.stop
/>
</div>
<div class="max-h-52 overflow-auto">
<button
v-for="model in filteredModels"
:key="model.value"
type="button"
@click="toggleModel(model.value)"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-600"
>
<span
:class="[
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
modelValue.includes(model.value)
? 'border-primary-500 bg-primary-500 text-white'
: 'border-gray-300 dark:border-dark-500'
]"
>
<svg v-if="modelValue.includes(model.value)" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
<ModelIcon :model="model.value" size="18px" />
<span class="truncate text-gray-900 dark:text-white">{{ model.value }}</span>
</button>
<div v-if="filteredModels.length === 0" class="px-3 py-4 text-center text-sm text-gray-500">
{{ t('admin.accounts.noMatchingModels') }}
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mb-4 flex flex-wrap gap-2">
<button
type="button"
@click="fillRelated"
class="rounded-lg border border-blue-200 px-3 py-1.5 text-sm text-blue-600 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
{{ t('admin.accounts.fillRelatedModels') }}
</button>
<button
type="button"
@click="clearAll"
class="rounded-lg border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/30"
>
{{ t('admin.accounts.clearAllModels') }}
</button>
</div>
<!-- Custom Model Input -->
<div class="mb-3">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.customModelName') }}</label>
<div class="flex gap-2">
<input
v-model="customModel"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.enterCustomModelName')"
@keydown.enter.prevent="handleEnter"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<button
type="button"
@click="addCustom"
class="rounded-lg bg-primary-50 px-4 py-2 text-sm font-medium text-primary-600 hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
{{ t('admin.accounts.addModel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ModelIcon from '@/components/common/ModelIcon.vue'
import Icon from '@/components/icons/Icon.vue'
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
const { t } = useI18n()
const props = defineProps<{
modelValue: string[]
platform?: string
platforms?: string[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const appStore = useAppStore()
const showDropdown = ref(false)
const searchQuery = ref('')
const customModel = ref('')
const isComposing = ref(false)
const normalizedPlatforms = computed(() => {
const rawPlatforms =
props.platforms && props.platforms.length > 0
? props.platforms
: props.platform
? [props.platform]
: []
return Array.from(
new Set(
rawPlatforms
.map(platform => platform?.trim())
.filter((platform): platform is string => Boolean(platform))
)
)
})
const availableOptions = computed(() => {
if (normalizedPlatforms.value.length === 0) {
return allModels
}
const allowedModels = new Set<string>()
for (const platform of normalizedPlatforms.value) {
for (const model of getModelsByPlatform(platform)) {
allowedModels.add(model)
}
}
return allModels.filter(model => allowedModels.has(model.value))
})
const filteredModels = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return availableOptions.value
return availableOptions.value.filter(
m => m.value.toLowerCase().includes(query) || m.label.toLowerCase().includes(query)
)
})
const toggleDropdown = () => {
showDropdown.value = !showDropdown.value
if (!showDropdown.value) searchQuery.value = ''
}
const removeModel = (model: string) => {
emit('update:modelValue', props.modelValue.filter(m => m !== model))
}
const toggleModel = (model: string) => {
if (props.modelValue.includes(model)) {
removeModel(model)
} else {
emit('update:modelValue', [...props.modelValue, model])
}
}
const addCustom = () => {
const model = customModel.value.trim()
if (!model) return
if (props.modelValue.includes(model)) {
appStore.showInfo(t('admin.accounts.modelExists'))
return
}
emit('update:modelValue', [...props.modelValue, model])
customModel.value = ''
}
const handleEnter = () => {
if (!isComposing.value) addCustom()
}
const fillRelated = () => {
const newModels = [...props.modelValue]
for (const platform of normalizedPlatforms.value) {
for (const model of getModelsByPlatform(platform)) {
if (!newModels.includes(model)) {
newModels.push(model)
}
}
}
emit('update:modelValue', newModels)
}
const clearAll = () => {
emit('update:modelValue', [])
}
</script>

View File

@@ -0,0 +1,992 @@
<template>
<div
class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">
<Icon name="link" size="md" class="text-white" />
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
<!-- Auth Method Selection -->
<div v-if="showMethodSelection" class="mb-4">
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
{{ methodLabel }}
</label>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="manual"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.manualAuth')
}}</span>
</label>
<label v-if="showCookieOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="cookie"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.cookieAutoAuth')
}}</span>
</label>
<label v-if="showRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="refresh_token"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t(getOAuthKey('refreshTokenAuth'))
}}</span>
</label>
<label v-if="showSessionTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="session_token"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t(getOAuthKey('sessionTokenAuth'))
}}</span>
</label>
<label v-if="showAccessTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="access_token"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
}}</span>
</label>
</div>
</div>
<!-- Refresh Token Input (OpenAI / Antigravity) -->
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t(getOAuthKey('refreshTokenDesc')) }}
</p>
<!-- Refresh Token Input -->
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
Refresh Token
<span
v-if="parsedRefreshTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedRefreshTokenCount }) }}
</span>
</label>
<textarea
v-model="refreshTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
></textarea>
<p
v-if="parsedRefreshTokenCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedRefreshTokenCount }) }}
</p>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<!-- Validate Button -->
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !refreshTokenInput.trim()"
@click="handleValidateRefreshToken"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{
loading
? t(getOAuthKey('validating'))
: t(getOAuthKey('validateAndCreate'))
}}
</button>
</div>
</div>
<!-- Session Token Input (Sora) -->
<div v-if="inputMethod === 'session_token'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t(getOAuthKey('sessionTokenDesc')) }}
</p>
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
{{ t(getOAuthKey('sessionTokenRawLabel')) }}
<span
v-if="parsedSessionTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedSessionTokenCount }) }}
</span>
</label>
<textarea
v-model="sessionTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('sessionTokenRawPlaceholder'))"
></textarea>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
{{ t(getOAuthKey('sessionTokenRawHint')) }}
</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
class="btn btn-secondary px-2 py-1 text-xs"
@click="handleOpenSoraSessionUrl"
>
{{ t(getOAuthKey('openSessionUrl')) }}
</button>
<button
type="button"
class="btn btn-secondary px-2 py-1 text-xs"
@click="handleCopySoraSessionUrl"
>
{{ t(getOAuthKey('copySessionUrl')) }}
</button>
</div>
<p class="mt-1 break-all text-xs text-blue-600 dark:text-blue-400">
{{ soraSessionUrl }}
</p>
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t(getOAuthKey('sessionUrlHint')) }}
</p>
<p
v-if="parsedSessionTokenCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedSessionTokenCount }) }}
</p>
</div>
<div v-if="sessionTokenInput.trim()" class="mb-4 space-y-3">
<div>
<label
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
>
{{ t(getOAuthKey('parsedSessionTokensLabel')) }}
<span
v-if="parsedSessionTokenCount > 0"
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
>
{{ parsedSessionTokenCount }}
</span>
</label>
<textarea
:value="parsedSessionTokensText"
rows="2"
readonly
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
></textarea>
<p
v-if="parsedSessionTokenCount === 0"
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
>
{{ t(getOAuthKey('parsedSessionTokensEmpty')) }}
</p>
</div>
<div>
<label
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
>
{{ t(getOAuthKey('parsedAccessTokensLabel')) }}
<span
v-if="parsedAccessTokenFromSessionInputCount > 0"
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
>
{{ parsedAccessTokenFromSessionInputCount }}
</span>
</label>
<textarea
:value="parsedAccessTokensText"
rows="2"
readonly
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
></textarea>
</div>
</div>
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || parsedSessionTokenCount === 0"
@click="handleValidateSessionToken"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{
loading
? t(getOAuthKey('validating'))
: t(getOAuthKey('validateAndCreate'))
}}
</button>
</div>
</div>
<!-- Access Token Input (Sora) -->
<div v-if="inputMethod === 'access_token'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.openai.accessTokenDesc', '直接粘贴 Access Token 创建账号,无需 OAuth 授权流程。支持批量导入(每行一个)。') }}
</p>
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
Access Token
<span
v-if="parsedAccessTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedAccessTokenCount }) }}
</span>
</label>
<textarea
v-model="accessTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t('admin.accounts.oauth.openai.accessTokenPlaceholder', '粘贴 Access Token每行一个')"
></textarea>
<p
v-if="parsedAccessTokenCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedAccessTokenCount }) }}
</p>
</div>
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !accessTokenInput.trim()"
@click="handleImportAccessToken"
>
<Icon name="sparkles" size="sm" class="mr-2" />
{{ t('admin.accounts.oauth.openai.importAccessToken', '导入 Access Token') }}
</button>
</div>
</div>
<!-- Cookie Auto-Auth Form -->
<div v-if="inputMethod === 'cookie'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.cookieAutoAuthDesc') }}
</p>
<!-- sessionKey Input -->
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
{{ t('admin.accounts.oauth.sessionKey') }}
<span
v-if="parsedKeyCount > 1 && allowMultiple"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedKeyCount }) }}
</span>
<button
v-if="showHelp"
type="button"
class="text-blue-500 hover:text-blue-600"
@click="showHelpDialog = !showHelpDialog"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
</button>
</label>
<textarea
v-model="sessionKeyInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="
allowMultiple
? t('admin.accounts.oauth.sessionKeyPlaceholder')
: t('admin.accounts.oauth.sessionKeyPlaceholderSingle')
"
></textarea>
<p
v-if="parsedKeyCount > 1 && allowMultiple"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedKeyCount }) }}
</p>
</div>
<!-- Help Section -->
<div
v-if="showHelpDialog && showHelp"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
{{ t('admin.accounts.oauth.howToGetSessionKey') }}
</h5>
<ol
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
>
<li>{{ t('admin.accounts.oauth.step1') }}</li>
<li>{{ t('admin.accounts.oauth.step2') }}</li>
<li>{{ t('admin.accounts.oauth.step3') }}</li>
<li>{{ t('admin.accounts.oauth.step4') }}</li>
<li>{{ t('admin.accounts.oauth.step5') }}</li>
<li>{{ t('admin.accounts.oauth.step6') }}</li>
</ol>
<p
class="mt-2 text-xs text-amber-600 dark:text-amber-400"
v-text="t('admin.accounts.oauth.sessionKeyFormat')"
></p>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
<!-- Auth Button -->
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !sessionKeyInput.trim()"
@click="handleCookieAuth"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{
loading
? t('admin.accounts.oauth.authorizing')
: t('admin.accounts.oauth.startAutoAuth')
}}
</button>
</div>
</div>
<!-- Manual Authorization Flow -->
<div v-if="inputMethod === 'manual'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
{{ oauthFollowSteps }}
</p>
<!-- Step 1: Generate Auth URL -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ oauthStep1GenerateUrl }}
</p>
<div v-if="showProjectId && platform === 'gemini'" class="mb-3">
<label class="input-label flex items-center gap-2">
{{ t('admin.accounts.oauth.gemini.projectIdLabel') }}
<a
href="https://console.cloud.google.com/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs font-normal text-blue-500 hover:text-blue-600 dark:text-blue-400"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
{{ t('admin.accounts.oauth.gemini.howToGetProjectId') }}
</a>
</label>
<input
v-model="projectId"
type="text"
class="input w-full font-mono text-sm"
:placeholder="t('admin.accounts.oauth.gemini.projectIdPlaceholder')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.oauth.gemini.projectIdHint') }}
</p>
</div>
<button
v-if="!authUrl"
type="button"
:disabled="loading"
class="btn btn-primary text-sm"
@click="handleGenerateUrl"
>
<svg
v-if="loading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="link" size="sm" class="mr-2" />
{{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }}
</button>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
:value="authUrl"
readonly
type="text"
class="input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
/>
<button
type="button"
class="btn btn-secondary p-2"
title="Copy URL"
@click="handleCopyUrl"
>
<svg
v-if="!copied"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
<Icon
v-else
name="check"
size="sm"
class="text-green-500"
:stroke-width="2"
/>
</button>
</div>
<button
type="button"
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@click="handleRegenerate"
>
<Icon name="refresh" size="xs" class="mr-1 inline" />
{{ t('admin.accounts.oauth.regenerate') }}
</button>
</div>
</div>
</div>
</div>
<!-- Step 2: Open URL and authorize -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
2
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ oauthStep2OpenUrl }}
</p>
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ oauthOpenUrlDesc }}
</p>
<!-- OpenAI Important Notice -->
<div
v-if="isOpenAI"
class="mt-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<p
class="text-xs text-amber-800 dark:text-amber-300"
v-text="oauthImportantNotice"
></p>
</div>
<!-- Proxy Warning (for non-OpenAI) -->
<div
v-else-if="showProxyWarning"
class="mt-2 rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p
class="text-xs text-yellow-800 dark:text-yellow-300"
v-text="t('admin.accounts.oauth.proxyWarning')"
></p>
</div>
</div>
</div>
</div>
<!-- Step 3: Enter authorization code -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ oauthStep3EnterCode }}
</p>
<p
class="mb-3 text-sm text-blue-700 dark:text-blue-300"
v-text="oauthAuthCodeDesc"
></p>
<div>
<label class="input-label">
<Icon name="key" size="sm" class="mr-1 inline text-blue-500" />
{{ oauthAuthCode }}
</label>
<textarea
v-model="authCodeInput"
rows="3"
class="input w-full resize-none font-mono text-sm"
:placeholder="oauthAuthCodePlaceholder"
></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<Icon name="infoCircle" size="xs" class="mr-1 inline" />
{{ oauthAuthCodeHint }}
</p>
<!-- Gemini-specific state parameter warning -->
<div
v-if="platform === 'gemini'"
class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 p-3 dark:border-amber-600 dark:bg-amber-900/30"
>
<div class="flex items-start gap-2">
<Icon
name="exclamationTriangle"
size="md"
class="flex-shrink-0 text-amber-600 dark:text-amber-400"
:stroke-width="2"
/>
<div class="text-sm text-amber-800 dark:text-amber-300">
<p class="font-semibold">{{ $t('admin.accounts.oauth.gemini.stateWarningTitle') }}</p>
<p class="mt-1">{{ $t('admin.accounts.oauth.gemini.stateWarningDesc') }}</p>
</div>
</div>
</div>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@/composables/useClipboard'
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
import Icon from '@/components/icons/Icon.vue'
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
import type { AccountPlatform } from '@/types'
interface Props {
addMethod: AddMethod
authUrl?: string
sessionId?: string
loading?: boolean
error?: string
showHelp?: boolean
showProxyWarning?: boolean
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
showAccessTokenOption?: boolean // Whether to show access token input option (Sora only)
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
const props = withDefaults(defineProps<Props>(), {
authUrl: '',
sessionId: '',
loading: false,
error: '',
showHelp: true,
showProxyWarning: true,
allowMultiple: false,
methodLabel: 'Authorization Method',
showCookieOption: true,
showRefreshTokenOption: false,
showSessionTokenOption: false,
showAccessTokenOption: false,
platform: 'anthropic',
showProjectId: true
})
const emit = defineEmits<{
'generate-url': []
'exchange-code': [code: string]
'cookie-auth': [sessionKey: string]
'validate-refresh-token': [refreshToken: string]
'validate-session-token': [sessionToken: string]
'import-access-token': [accessToken: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
const { t } = useI18n()
const isOpenAI = computed(() => props.platform === 'openai' || props.platform === 'sora')
// Get translation key based on platform
const getOAuthKey = (key: string) => {
if (props.platform === 'openai' || props.platform === 'sora') return `admin.accounts.oauth.openai.${key}`
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
return `admin.accounts.oauth.${key}`
}
// Computed translations for current platform
const oauthTitle = computed(() => t(getOAuthKey('title')))
const oauthFollowSteps = computed(() => t(getOAuthKey('followSteps')))
const oauthStep1GenerateUrl = computed(() => t(getOAuthKey('step1GenerateUrl')))
const oauthGenerateAuthUrl = computed(() => t(getOAuthKey('generateAuthUrl')))
const oauthStep2OpenUrl = computed(() => t(getOAuthKey('step2OpenUrl')))
const oauthOpenUrlDesc = computed(() => t(getOAuthKey('openUrlDesc')))
const oauthStep3EnterCode = computed(() => t(getOAuthKey('step3EnterCode')))
const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
const oauthImportantNotice = computed(() => {
if (props.platform === 'openai' || props.platform === 'sora') return t('admin.accounts.oauth.openai.importantNotice')
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
return ''
})
// Local state
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
const accessTokenInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
// Computed
const parsedKeyCount = computed(() => {
return sessionKeyInput.value
.split('\n')
.map((k) => k.trim())
.filter((k) => k).length
})
// Computed: count of refresh tokens entered
const parsedRefreshTokenCount = computed(() => {
return refreshTokenInput.value
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt).length
})
const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value))
const parsedSessionTokenCount = computed(() => {
return parsedSoraRawTokens.value.sessionTokens.length
})
const parsedSessionTokensText = computed(() => {
return parsedSoraRawTokens.value.sessionTokens.join('\n')
})
const parsedAccessTokenFromSessionInputCount = computed(() => {
return parsedSoraRawTokens.value.accessTokens.length
})
const parsedAccessTokensText = computed(() => {
return parsedSoraRawTokens.value.accessTokens.join('\n')
})
const soraSessionUrl = 'https://sora.chatgpt.com/api/auth/session'
const parsedAccessTokenCount = computed(() => {
return accessTokenInput.value
.split('\n')
.map((at) => at.trim())
.filter((at) => at).length
})
// Watchers
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
// e.g., http://localhost:8085/callback?code=xxx...&state=...
watch(authCodeInput, (newVal) => {
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'sora') return
const trimmed = newVal.trim()
// Check if it looks like a URL with code parameter
if (trimmed.includes('?') && trimmed.includes('code=')) {
try {
// Try to parse as URL
const url = new URL(trimmed)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
oauthState.value = stateParam
}
if (code && code !== trimmed) {
// Replace the input with just the code
authCodeInput.value = code
}
} catch {
// If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/)
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
oauthState.value = stateMatch[1]
}
if (match && match[1] && match[1] !== trimmed) {
authCodeInput.value = match[1]
}
}
}
})
// Methods
const handleGenerateUrl = () => {
emit('generate-url')
}
const handleCopyUrl = () => {
if (props.authUrl) {
copyToClipboard(props.authUrl, 'URL copied to clipboard')
}
}
const handleRegenerate = () => {
authCodeInput.value = ''
emit('generate-url')
}
const handleCookieAuth = () => {
if (sessionKeyInput.value.trim()) {
emit('cookie-auth', sessionKeyInput.value)
}
}
const handleValidateRefreshToken = () => {
if (refreshTokenInput.value.trim()) {
emit('validate-refresh-token', refreshTokenInput.value.trim())
}
}
const handleValidateSessionToken = () => {
if (parsedSessionTokenCount.value > 0) {
emit('validate-session-token', parsedSessionTokensText.value)
}
}
const handleOpenSoraSessionUrl = () => {
window.open(soraSessionUrl, '_blank', 'noopener,noreferrer')
}
const handleCopySoraSessionUrl = () => {
copyToClipboard(soraSessionUrl, 'URL copied to clipboard')
}
const handleImportAccessToken = () => {
if (accessTokenInput.value.trim()) {
emit('import-access-token', accessTokenInput.value.trim())
}
}
// Expose methods and state
defineExpose({
authCode: authCodeInput,
oauthState,
projectId,
sessionKey: sessionKeyInput,
refreshToken: refreshTokenInput,
sessionToken: sessionTokenInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
oauthState.value = ''
projectId.value = ''
sessionKeyInput.value = ''
refreshTokenInput.value = ''
sessionTokenInput.value = ''
inputMethod.value = 'manual'
showHelpDialog.value = false
}
})
</script>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
used: number
limit: number
label?: string // 文字前缀,如 "D" / "W";不传时显示 icon
}>()
const { t } = useI18n()
const badgeClass = computed(() => {
if (props.used >= props.limit) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (props.used >= props.limit * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
const tooltip = computed(() => {
if (props.used >= props.limit) {
return t('admin.accounts.capacity.quota.exceeded')
}
return t('admin.accounts.capacity.quota.normal')
})
const fmt = (v: number) => v.toFixed(2)
</script>
<template>
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-px text-[10px] font-medium leading-tight',
badgeClass
]"
:title="tooltip"
>
<span v-if="label" class="font-semibold opacity-70">{{ label }}</span>
<svg v-else class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
<span class="font-mono">${{ fmt(used) }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">${{ fmt(limit) }}</span>
</span>
</template>

View File

@@ -0,0 +1,295 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
totalLimit: number | null
dailyLimit: number | null
weeklyLimit: number | null
dailyResetMode: 'rolling' | 'fixed' | null
dailyResetHour: number | null
weeklyResetMode: 'rolling' | 'fixed' | null
weeklyResetDay: number | null
weeklyResetHour: number | null
resetTimezone: string | null
}>()
const emit = defineEmits<{
'update:totalLimit': [value: number | null]
'update:dailyLimit': [value: number | null]
'update:weeklyLimit': [value: number | null]
'update:dailyResetMode': [value: 'rolling' | 'fixed' | null]
'update:dailyResetHour': [value: number | null]
'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null]
'update:weeklyResetDay': [value: number | null]
'update:weeklyResetHour': [value: number | null]
'update:resetTimezone': [value: string | null]
}>()
const enabled = computed(() =>
(props.totalLimit != null && props.totalLimit > 0) ||
(props.dailyLimit != null && props.dailyLimit > 0) ||
(props.weeklyLimit != null && props.weeklyLimit > 0)
)
const localEnabled = ref(enabled.value)
// Sync when props change externally
watch(enabled, (val) => {
localEnabled.value = val
})
// When toggle is turned off, clear all values
watch(localEnabled, (val) => {
if (!val) {
emit('update:totalLimit', null)
emit('update:dailyLimit', null)
emit('update:weeklyLimit', null)
emit('update:dailyResetMode', null)
emit('update:dailyResetHour', null)
emit('update:weeklyResetMode', null)
emit('update:weeklyResetDay', null)
emit('update:weeklyResetHour', null)
emit('update:resetTimezone', null)
}
})
// Whether any fixed mode is active (to show timezone selector)
const hasFixedMode = computed(() =>
props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed'
)
// Common timezone options
const timezoneOptions = [
'UTC',
'Asia/Shanghai',
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Singapore',
'Asia/Kolkata',
'Asia/Dubai',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Sao_Paulo',
'Australia/Sydney',
'Pacific/Auckland',
]
// Hours for dropdown (0-23)
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
// Day of week options
const dayOptions = [
{ value: 1, key: 'monday' },
{ value: 2, key: 'tuesday' },
{ value: 3, key: 'wednesday' },
{ value: 4, key: 'thursday' },
{ value: 5, key: 'friday' },
{ value: 6, key: 'saturday' },
{ value: 0, key: 'sunday' },
]
const onTotalInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:totalLimit', Number.isNaN(raw) ? null : raw)
}
const onDailyInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:dailyLimit', Number.isNaN(raw) ? null : raw)
}
const onWeeklyInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:weeklyLimit', Number.isNaN(raw) ? null : raw)
}
const onDailyModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
emit('update:dailyResetMode', val)
if (val === 'fixed') {
if (props.dailyResetHour == null) emit('update:dailyResetHour', 0)
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
const onWeeklyModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
emit('update:weeklyResetMode', val)
if (val === 'fixed') {
if (props.weeklyResetDay == null) emit('update:weeklyResetDay', 1)
if (props.weeklyResetHour == null) emit('update:weeklyResetHour', 0)
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
</script>
<template>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.quotaLimitToggle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitToggleHint') }}
</p>
</div>
<button
type="button"
@click="localEnabled = !localEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
localEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
localEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="localEnabled" class="space-y-3">
<!-- 日配额 -->
<div>
<label class="input-label">{{ t('admin.accounts.quotaDailyLimit') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
:value="dailyLimit"
@input="onDailyInput"
type="number"
min="0"
step="0.01"
class="input pl-7"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/>
</div>
<!-- 日配额重置模式 -->
<div class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
<select
:value="dailyResetMode || 'rolling'"
@change="onDailyModeChange"
class="input py-1 text-xs"
>
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
</select>
</div>
<!-- 固定模式小时选择 -->
<div v-if="dailyResetMode === 'fixed'" class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
<select
:value="dailyResetHour ?? 0"
@change="emit('update:dailyResetHour', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-24"
>
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
</div>
<p class="input-hint">
<template v-if="dailyResetMode === 'fixed'">
{{ t('admin.accounts.quotaDailyLimitHintFixed', { hour: String(dailyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}
</template>
<template v-else>
{{ t('admin.accounts.quotaDailyLimitHint') }}
</template>
</p>
</div>
<!-- 周配额 -->
<div>
<label class="input-label">{{ t('admin.accounts.quotaWeeklyLimit') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
:value="weeklyLimit"
@input="onWeeklyInput"
type="number"
min="0"
step="0.01"
class="input pl-7"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/>
</div>
<!-- 周配额重置模式 -->
<div class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
<select
:value="weeklyResetMode || 'rolling'"
@change="onWeeklyModeChange"
class="input py-1 text-xs"
>
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
</select>
</div>
<!-- 固定模式星期几 + 小时 -->
<div v-if="weeklyResetMode === 'fixed'" class="mt-2 flex items-center gap-2 flex-wrap">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaWeeklyResetDay') }}</label>
<select
:value="weeklyResetDay ?? 1"
@change="emit('update:weeklyResetDay', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-28"
>
<option v-for="d in dayOptions" :key="d.value" :value="d.value">{{ t('admin.accounts.dayOfWeek.' + d.key) }}</option>
</select>
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
<select
:value="weeklyResetHour ?? 0"
@change="emit('update:weeklyResetHour', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-24"
>
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
</div>
<p class="input-hint">
<template v-if="weeklyResetMode === 'fixed'">
{{ t('admin.accounts.quotaWeeklyLimitHintFixed', { day: t('admin.accounts.dayOfWeek.' + (dayOptions.find(d => d.value === (weeklyResetDay ?? 1))?.key || 'monday')), hour: String(weeklyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}
</template>
<template v-else>
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
</template>
</p>
</div>
<!-- 时区选择当任一维度使用固定模式时显示 -->
<div v-if="hasFixedMode">
<label class="input-label">{{ t('admin.accounts.quotaResetTimezone') }}</label>
<select
:value="resetTimezone || 'UTC'"
@change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)"
class="input text-sm"
>
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }}</option>
</select>
</div>
<!-- 总配额 -->
<div>
<label class="input-label">{{ t('admin.accounts.quotaTotalLimit') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
:value="totalLimit"
@input="onTotalInput"
type="number"
min="0"
step="0.01"
class="input pl-7"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,548 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.reAuthorizeAccount')"
width="normal"
@close="handleClose"
>
<div v-if="account" class="space-y-4">
<!-- Account Info -->
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAILike
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: isAntigravity
? 'from-purple-500 to-purple-600'
: 'from-orange-500 to-orange-600'
]"
>
<Icon name="sparkles" size="md" class="text-white" />
</div>
<div>
<span class="block font-semibold text-gray-900 dark:text-white">{{
account.name
}}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isSora
? t('admin.accounts.soraAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: isAntigravity
? t('admin.accounts.antigravityAccount')
: t('admin.accounts.claudeCodeAccount')
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection (Claude only) -->
<fieldset v-if="isAnthropic" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.types.oauth')
}}</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.setupTokenLongLived')
}}</span>
</label>
</div>
</fieldset>
<!-- Gemini OAuth Type Display (read-only) -->
<div v-if="isGemini" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}
</div>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-amber-500 text-white'
]"
>
<Icon v-if="geminiOAuthType === 'google_one'" name="user" size="sm" />
<Icon v-else-if="geminiOAuthType === 'code_assist'" name="cloud" size="sm" />
<Icon v-else name="sparkles" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{
geminiOAuthType === 'google_one'
? 'Google One'
: geminiOAuthType === 'code_assist'
? t('admin.accounts.gemini.oauthType.builtInTitle')
: t('admin.accounts.gemini.oauthType.customTitle')
}}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{
geminiOAuthType === 'google_one'
? '个人账号'
: geminiOAuthType === 'code_assist'
? t('admin.accounts.gemini.oauthType.builtInDesc')
: t('admin.accounts.gemini.oauthType.customDesc')
}}
</span>
</div>
</div>
</div>
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentLoading"
:error="currentError"
:show-help="isAnthropic"
:show-proxy-warning="isAnthropic"
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
</div>
<template #footer>
<div v-if="account" class="flex justify-between gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
v-if="isManualInputMethod"
type="button"
:disabled="!canExchangeCode"
class="btn btn-primary"
@click="handleExchangeCode"
>
<svg
v-if="currentLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
currentLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import {
useAccountOAuth,
type AddMethod,
type AuthInputMethod
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Account } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface OAuthFlowExposed {
authCode: string
oauthState: string
projectId: string
sessionKey: string
inputMethod: AuthInputMethod
reset: () => void
}
interface Props {
show: boolean
account: Account | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
reauthorized: []
}>()
const appStore = useAppStore()
const { t } = useI18n()
// OAuth composables
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
// Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isSora = computed(() => props.account?.platform === 'sora')
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
})
// Computed
const isManualInputMethod = computed(() => {
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = currentSessionId.value
const loading = currentLoading.value
return authCode.trim() && sessionId && !loading
})
// Watchers
watch(
() => props.show,
(newVal) => {
if (newVal && props.account) {
// Initialize addMethod based on current account type (Claude only)
if (
isAnthropic.value &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
) {
addMethod.value = props.account.type as AddMethod
}
if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
geminiOAuthType.value =
creds.oauth_type === 'google_one'
? 'google_one'
: creds.oauth_type === 'ai_studio'
? 'ai_studio'
: 'code_assist'
}
} else {
resetState()
}
}
)
// Methods
const resetState = () => {
addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState()
openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
}
const handleClose = () => {
emit('close')
}
const handleGenerateUrl = async () => {
if (!props.account) return
if (isOpenAILike.value) {
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
} else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
}
const handleExchangeCode = async () => {
if (!props.account) return
const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim()) return
if (isOpenAILike.value) {
// OpenAI OAuth flow
const oauthClient = activeOpenAIOAuth.value
const sessionId = oauthClient.sessionId.value
if (!sessionId) return
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
if (!stateToUse) {
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
return
}
const tokenInfo = await oauthClient.exchangeAuthCode(
authCode.trim(),
sessionId,
stateToUse,
props.account.proxy_id
)
if (!tokenInfo) return
// Build credentials and extra info
const credentials = oauthClient.buildCredentials(tokenInfo)
const extra = oauthClient.buildExtraInfo(tokenInfo)
try {
// Update account with new credentials
await adminAPI.accounts.update(props.account.id, {
type: 'oauth', // OpenAI OAuth is always 'oauth' type
credentials,
extra
})
// Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
}
} else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || geminiOAuth.state.value
if (!stateToUse) return
const tokenInfo = await geminiOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id,
oauthType: geminiOAuthType.value,
tierId: typeof (props.account.credentials as any)?.tier_id === 'string' ? ((props.account.credentials as any).tier_id as string) : undefined
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
}
} else if (isAntigravity.value) {
// Antigravity OAuth flow
const sessionId = antigravityOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || antigravityOAuth.state.value
if (!stateToUse) return
const tokenInfo = await antigravityOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id
})
if (!tokenInfo) return
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value)
}
} else {
// Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value
if (!sessionId) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId,
code: authCode.trim(),
...proxyConfig
})
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
// Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(claudeOAuth.error.value)
} finally {
claudeOAuth.loading.value = false
}
}
}
const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAILike.value) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
code: sessionKey.trim(),
...proxyConfig
})
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
// Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
claudeOAuth.error.value =
error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
claudeOAuth.loading.value = false
}
}
</script>

View File

@@ -0,0 +1,395 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.syncFromCrsTitle')"
width="normal"
close-on-click-outside
@close="handleClose"
>
<!-- Step 1: Input credentials -->
<form
v-if="currentStep === 'input'"
id="sync-from-crs-form"
class="space-y-4"
@submit.prevent="handlePreview"
>
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }}
</div>
<div
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
{{ t('admin.accounts.crsUpdateBehaviorNote') }}
</div>
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('admin.accounts.crsVersionRequirement') }}
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label for="crs-base-url" class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
<input
id="crs-base-url"
v-model="form.base_url"
type="text"
class="input"
required
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="crs-username" class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input id="crs-username" v-model="form.username" type="text" class="input" required autocomplete="username" />
</div>
<div>
<label for="crs-password" class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
<input
id="crs-password"
v-model="form.password"
type="password"
class="input"
required
autocomplete="current-password"
/>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300">
<input
v-model="form.sync_proxies"
type="checkbox"
class="rounded border-gray-300 dark:border-dark-600"
/>
{{ t('admin.accounts.syncProxies') }}
</label>
</div>
</form>
<!-- Step 2: Preview & select -->
<div v-else-if="currentStep === 'preview' && previewResult" class="space-y-4">
<!-- Existing accounts (read-only info) -->
<div
v-if="previewResult.existing_accounts.length"
class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60"
>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-dark-300">
{{ t('admin.accounts.crsExistingAccounts') }}
<span class="ml-1 text-xs text-gray-400">({{ previewResult.existing_accounts.length }})</span>
</div>
<div class="max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400">
<div
v-for="acc in previewResult.existing_accounts"
:key="acc.crs_account_id"
class="flex items-center gap-2 py-0.5"
>
<span
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>{{ acc.platform }} / {{ acc.type }}</span>
<span class="truncate">{{ acc.name }}</span>
</div>
</div>
</div>
<!-- New accounts (selectable) -->
<div v-if="previewResult.new_accounts.length">
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.crsNewAccounts') }}
<span class="ml-1 text-xs text-gray-400">({{ previewResult.new_accounts.length }})</span>
</div>
<div class="flex gap-2">
<button
type="button"
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@click="selectAll"
>{{ t('admin.accounts.crsSelectAll') }}</button>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400"
@click="selectNone"
>{{ t('admin.accounts.crsSelectNone') }}</button>
</div>
</div>
<div
class="max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600"
>
<label
v-for="acc in previewResult.new_accounts"
:key="acc.crs_account_id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40"
>
<input
type="checkbox"
:checked="selectedIds.has(acc.crs_account_id)"
class="rounded border-gray-300 dark:border-dark-600"
@change="toggleSelect(acc.crs_account_id)"
/>
<span
class="inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>{{ acc.platform }} / {{ acc.type }}</span>
<span class="truncate text-sm text-gray-700 dark:text-dark-300">{{ acc.name }}</span>
</label>
</div>
<div class="mt-1 text-xs text-gray-400">
{{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }}
</div>
</div>
<!-- Sync options summary -->
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
<span>{{ t('admin.accounts.syncProxies') }}:</span>
<span :class="form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'">
{{ form.sync_proxies ? t('common.yes') : t('common.no') }}
</span>
</div>
<!-- No new accounts -->
<div
v-if="!previewResult.new_accounts.length"
class="rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
{{ t('admin.accounts.crsNoNewAccounts') }}
<span v-if="previewResult.existing_accounts.length">
{{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }}
</span>
</div>
</div>
<!-- Step 3: Result -->
<div v-else-if="currentStep === 'result' && result" class="space-y-4">
<div
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.syncResult') }}
</div>
<div class="text-sm text-gray-700 dark:text-dark-300">
{{ t('admin.accounts.syncResultSummary', result) }}
</div>
<div v-if="errorItems.length" class="mt-2">
<div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.accounts.syncErrors') }}
</div>
<div
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
{{ item.kind }} {{ item.crs_account_id }} {{ item.action
}}{{ item.error ? `: ${item.error}` : '' }}
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<!-- Step 1: Input -->
<template v-if="currentStep === 'input'">
<button
class="btn btn-secondary"
type="button"
:disabled="previewing"
@click="handleClose"
>
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="previewing"
>
{{ previewing ? t('admin.accounts.crsPreviewing') : t('admin.accounts.crsPreview') }}
</button>
</template>
<!-- Step 2: Preview -->
<template v-else-if="currentStep === 'preview'">
<button
class="btn btn-secondary"
type="button"
:disabled="syncing"
@click="handleBack"
>
{{ t('admin.accounts.crsBack') }}
</button>
<button
class="btn btn-primary"
type="button"
:disabled="syncing || hasNewButNoneSelected"
@click="handleSync"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button>
</template>
<!-- Step 3: Result -->
<template v-else-if="currentStep === 'result'">
<button class="btn btn-secondary" type="button" @click="handleClose">
{{ t('common.close') }}
</button>
</template>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { PreviewFromCRSResult } from '@/api/admin/accounts'
interface Props {
show: boolean
}
interface Emits {
(e: 'close'): void
(e: 'synced'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
type Step = 'input' | 'preview' | 'result'
const currentStep = ref<Step>('input')
const previewing = ref(false)
const syncing = ref(false)
const previewResult = ref<PreviewFromCRSResult | null>(null)
const selectedIds = ref(new Set<string>())
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
const form = reactive({
base_url: '',
username: '',
password: '',
sync_proxies: true
})
const hasNewButNoneSelected = computed(() => {
if (!previewResult.value) return false
return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0
})
const errorItems = computed(() => {
if (!result.value?.items) return []
return result.value.items.filter(
(i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected')
)
})
watch(
() => props.show,
(open) => {
if (open) {
currentStep.value = 'input'
previewResult.value = null
selectedIds.value = new Set()
result.value = null
form.base_url = ''
form.username = ''
form.password = ''
form.sync_proxies = true
}
}
)
const handleClose = () => {
if (syncing.value || previewing.value) {
return
}
emit('close')
}
const handleBack = () => {
currentStep.value = 'input'
previewResult.value = null
selectedIds.value = new Set()
}
const selectAll = () => {
if (!previewResult.value) return
selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id))
}
const selectNone = () => {
selectedIds.value = new Set()
}
const toggleSelect = (id: string) => {
const s = new Set(selectedIds.value)
if (s.has(id)) {
s.delete(id)
} else {
s.add(id)
}
selectedIds.value = s
}
const handlePreview = async () => {
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
appStore.showError(t('admin.accounts.syncMissingFields'))
return
}
previewing.value = true
try {
const res = await adminAPI.accounts.previewFromCrs({
base_url: form.base_url.trim(),
username: form.username.trim(),
password: form.password
})
previewResult.value = res
// Auto-select all new accounts
selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id))
currentStep.value = 'preview'
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed'))
} finally {
previewing.value = false
}
}
const handleSync = async () => {
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
appStore.showError(t('admin.accounts.syncMissingFields'))
return
}
syncing.value = true
try {
const res = await adminAPI.accounts.syncFromCrs({
base_url: form.base_url.trim(),
username: form.username.trim(),
password: form.password,
sync_proxies: form.sync_proxies,
selected_account_ids: [...selectedIds.value]
})
result.value = res
currentStep.value = 'result'
if (res.failed > 0) {
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
} else {
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
}
emit('synced')
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
} finally {
syncing.value = false
}
}
</script>

View File

@@ -0,0 +1,253 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.tempUnschedulable.statusTitle')"
width="normal"
@close="handleClose"
>
<div class="space-y-4">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="h-6 w-6 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<div v-else-if="!isActive" class="rounded-lg border border-gray-200 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.notActive') }}
</div>
<div v-else class="space-y-4">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
{{ t('admin.accounts.recoverStateHint') }}
</div>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.accountName') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ account?.name || '-' }}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.triggeredAt') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ triggeredAtText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.until') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ untilText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.remaining') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ remainingText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.errorCode') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ state?.status_code || '-' }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.matchedKeyword') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ state?.matched_keyword || '-' }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.ruleOrder') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ruleIndexDisplay }}
</p>
</div>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.errorMessage') }}
</p>
<div class="mt-2 rounded bg-gray-50 p-2 text-xs text-gray-700 dark:bg-dark-700 dark:text-gray-300">
{{ state?.error_message || '-' }}
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.close') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!isActive || resetting"
@click="handleReset"
>
<svg
v-if="resetting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('admin.accounts.recoverState') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Account, TempUnschedulableStatus } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { formatDateTime } from '@/utils/format'
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
close: []
reset: [account: Account]
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const resetting = ref(false)
const status = ref<TempUnschedulableStatus | null>(null)
const state = computed(() => status.value?.state || null)
const isActive = computed(() => {
if (!status.value?.active || !state.value) return false
return state.value.until_unix * 1000 > Date.now()
})
const ruleIndexDisplay = computed(() => {
if (!state.value) return '-'
return state.value.rule_index + 1
})
const triggeredAtText = computed(() => {
if (!state.value?.triggered_at_unix) return '-'
return formatDateTime(new Date(state.value.triggered_at_unix * 1000))
})
const untilText = computed(() => {
if (!state.value?.until_unix) return '-'
return formatDateTime(new Date(state.value.until_unix * 1000))
})
const remainingText = computed(() => {
if (!state.value) return '-'
const remainingMs = state.value.until_unix * 1000 - Date.now()
if (remainingMs <= 0) {
return t('admin.accounts.tempUnschedulable.expired')
}
const minutes = Math.ceil(remainingMs / 60000)
if (minutes < 60) {
return t('admin.accounts.tempUnschedulable.remainingMinutes', { minutes })
}
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (rest === 0) {
return t('admin.accounts.tempUnschedulable.remainingHours', { hours })
}
return t('admin.accounts.tempUnschedulable.remainingHoursMinutes', { hours, minutes: rest })
})
const loadStatus = async () => {
if (!props.account) return
loading.value = true
try {
status.value = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id)
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.tempUnschedulable.failedToLoad'))
status.value = null
} finally {
loading.value = false
}
}
const handleClose = () => {
emit('close')
}
const handleReset = async () => {
if (!props.account) return
resetting.value = true
try {
const updated = await adminAPI.accounts.recoverState(props.account.id)
appStore.showSuccess(t('admin.accounts.recoverStateSuccess'))
emit('reset', updated)
handleClose()
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.recoverStateFailed'))
} finally {
resetting.value = false
}
}
watch(
() => [props.show, props.account?.id],
([visible]) => {
if (visible && props.account) {
loadStatus()
return
}
status.value = null
}
)
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div>
<!-- Window stats row (above progress bar) -->
<div
v-if="windowStats"
class="mb-0.5 flex items-center"
>
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatRequests }} req
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
A ${{ formatAccountCost }}
</span>
<span
v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U ${{ formatUserCost }}
</span>
</div>
</div>
<!-- Progress bar row -->
<div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) -->
<span
:class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
>
{{ label }}
</span>
<!-- Progress bar container -->
<div class="h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
:class="['h-full transition-all duration-300', barClass]"
:style="{ width: barWidth }"
></div>
</div>
<!-- Percentage -->
<span :class="['w-[32px] shrink-0 text-right text-[10px] font-medium', textClass]">
{{ displayPercent }}
</span>
<!-- Reset time -->
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
{{ formatResetTime }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WindowStats } from '@/types'
const props = defineProps<{
label: string
utilization: number // Percentage (0-100+)
resetsAt?: string | null
color: 'indigo' | 'emerald' | 'purple' | 'amber'
windowStats?: WindowStats | null
}>()
// Label background colors
const labelClass = computed(() => {
const colors = {
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
}
return colors[props.color]
})
// Progress bar color based on utilization
const barClass = computed(() => {
if (props.utilization >= 100) {
return 'bg-red-500'
} else if (props.utilization >= 80) {
return 'bg-amber-500'
} else {
return 'bg-green-500'
}
})
// Text color based on utilization
const textClass = computed(() => {
if (props.utilization >= 100) {
return 'text-red-600 dark:text-red-400'
} else if (props.utilization >= 80) {
return 'text-amber-600 dark:text-amber-400'
} else {
return 'text-gray-600 dark:text-gray-400'
}
})
// Bar width (capped at 100%)
const barWidth = computed(() => {
return `${Math.min(props.utilization, 100)}%`
})
// Display percentage (cap at 999% for readability)
const displayPercent = computed(() => {
const percent = Math.round(props.utilization)
return percent > 999 ? '>999%' : `${percent}%`
})
// Format reset time
const formatResetTime = computed(() => {
if (!props.resetsAt) return '-'
const date = new Date(props.resetsAt)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
if (diffMs <= 0) return '现在'
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
if (diffHours >= 24) {
const days = Math.floor(diffHours / 24)
return `${days}d ${diffHours % 24}h`
} else if (diffHours > 0) {
return `${diffHours}h ${diffMins}m`
} else {
return `${diffMins}m`
}
})
// Window stats formatters
const formatRequests = computed(() => {
if (!props.windowStats) return ''
const r = props.windowStats.requests
if (r >= 1000000) return `${(r / 1000000).toFixed(1)}M`
if (r >= 1000) return `${(r / 1000).toFixed(1)}K`
return r.toString()
})
const formatTokens = computed(() => {
if (!props.windowStats) return ''
const t = props.windowStats.tokens
if (t >= 1000000000) return `${(t / 1000000000).toFixed(1)}B`
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
return t.toString()
})
const formatAccountCost = computed(() => {
if (!props.windowStats) return '0.00'
return props.windowStats.cost.toFixed(2)
})
const formatUserCost = computed(() => {
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
return props.windowStats.user_cost.toFixed(2)
})
</script>

View File

@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AccountStatusIndicator from '../AccountStatusIndicator.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
name: 'account',
platform: 'antigravity',
type: 'oauth',
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: true,
created_at: '2026-03-15T00:00:00Z',
updated_at: '2026-03-15T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
...overrides,
}
}
describe('AccountStatusIndicator', () => {
it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 1,
name: 'ag-1',
extra: {
allow_overages: true,
model_rate_limits: {
'claude-sonnet-4-5': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).toContain('⚡')
expect(wrapper.text()).toContain('CSon45')
})
it('模型限流 + overages 未启用 → 普通限流样式(无 ⚡)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 2,
name: 'ag-2',
extra: {
model_rate_limits: {
'claude-sonnet-4-5': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).toContain('CSon45')
expect(wrapper.text()).not.toContain('⚡')
})
it('AICredits key 生效 → 显示积分已用尽 (credits_exhausted)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 3,
name: 'ag-3',
extra: {
allow_overages: true,
model_rate_limits: {
'AICredits': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).toContain('account.creditsExhausted')
})
it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 4,
name: 'ag-4',
extra: {
allow_overages: true,
model_rate_limits: {
'claude-sonnet-4-5': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
},
'AICredits': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
// 模型限流 + 积分耗尽 → 不应显示 ⚡
expect(wrapper.text()).toContain('CSon45')
expect(wrapper.text()).not.toContain('⚡')
// AICredits 积分耗尽状态应显示
expect(wrapper.text()).toContain('account.creditsExhausted')
})
})

View File

@@ -0,0 +1,422 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountUsageCell from '../AccountUsageCell.vue'
import type { Account } from '@/types'
const { getUsage } = vi.hoisted(() => ({
getUsage: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
getUsage
}
}
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function makeAccount(overrides: Partial<Account>): Account {
return {
id: 1,
name: 'account',
platform: 'antigravity',
type: 'oauth',
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: true,
created_at: '2026-03-15T00:00:00Z',
updated_at: '2026-03-15T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null,
...overrides,
}
}
describe('AccountUsageCell', () => {
beforeEach(() => {
getUsage.mockReset()
})
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
getUsage.mockResolvedValue({
antigravity_quota: {
'gemini-2.5-flash-image': {
utilization: 45,
reset_time: '2026-03-01T11:00:00Z'
},
'gemini-3.1-flash-image': {
utilization: 20,
reset_time: '2026-03-01T10:00:00Z'
},
'gemini-3-pro-image': {
utilization: 70,
reset_time: '2026-03-01T09:00:00Z'
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 1001,
platform: 'antigravity',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z')
})
it('Antigravity 会显示 AI Credits 余额信息', async () => {
getUsage.mockResolvedValue({
ai_credits: [
{
credit_type: 'GOOGLE_ONE_AI',
amount: 25,
minimum_balance: 5
}
]
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 1002,
platform: 'antigravity',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.aiCreditsBalance')
expect(wrapper.text()).toContain('25')
})
it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 15,
resets_at: '2026-03-08T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 3,
tokens: 300,
cost: 0.03,
standard_cost: 0.03,
user_cost: 0.03
}
},
seven_day: {
utilization: 77,
resets_at: '2026-03-13T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 3,
tokens: 300,
cost: 0.03,
standard_cost: 0.03,
user_cost: 0.03
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2000,
platform: 'openai',
type: 'oauth',
extra: {
codex_usage_updated_at: '2026-03-07T00:00:00Z',
codex_5h_used_percent: 12,
codex_5h_reset_at: '2026-03-08T12:00:00Z',
codex_7d_used_percent: 34,
codex_7d_reset_at: '2026-03-13T12:00:00Z'
}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2000)
expect(wrapper.text()).toContain('5h|15|300')
expect(wrapper.text()).toContain('7d|77|300')
})
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2001,
platform: 'openai',
type: 'oauth',
extra: {
codex_usage_updated_at: '2099-03-07T10:00:00Z',
codex_5h_used_percent: 12,
codex_5h_reset_at: '2099-03-07T12:00:00Z',
codex_7d_used_percent: 34,
codex_7d_reset_at: '2099-03-13T12:00:00Z'
}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(getUsage).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('5h|12')
expect(wrapper.text()).toContain('7d|34')
})
it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 0,
resets_at: null,
remaining_seconds: 0,
window_stats: {
requests: 2,
tokens: 27700,
cost: 0.06,
standard_cost: 0.06,
user_cost: 0.06
}
},
seven_day: {
utilization: 0,
resets_at: null,
remaining_seconds: 0,
window_stats: {
requests: 2,
tokens: 27700,
cost: 0.06,
standard_cost: 0.06,
user_cost: 0.06
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2002,
platform: 'openai',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2002)
expect(wrapper.text()).toContain('5h|0|27700')
expect(wrapper.text()).toContain('7d|0|27700')
})
it('OpenAI OAuth 在行数据刷新但仍无 codex 快照时会重新拉取 usage', async () => {
getUsage
.mockResolvedValueOnce({
five_hour: {
utilization: 0,
resets_at: null,
remaining_seconds: 0,
window_stats: {
requests: 1,
tokens: 100,
cost: 0.01,
standard_cost: 0.01,
user_cost: 0.01
}
},
seven_day: null
})
.mockResolvedValueOnce({
five_hour: {
utilization: 0,
resets_at: null,
remaining_seconds: 0,
window_stats: {
requests: 2,
tokens: 200,
cost: 0.02,
standard_cost: 0.02,
user_cost: 0.02
}
},
seven_day: null
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2003,
platform: 'openai',
type: 'oauth',
updated_at: '2026-03-07T10:00:00Z',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('5h|0|100')
expect(getUsage).toHaveBeenCalledTimes(1)
await wrapper.setProps({
account: {
id: 2003,
platform: 'openai',
type: 'oauth',
updated_at: '2026-03-07T10:01:00Z',
extra: {}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('5h|0|200')
})
it('OpenAI OAuth 已限额时首屏优先展示重新查询后的 usage而不是旧 codex 快照', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 100,
resets_at: '2026-03-07T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 211,
tokens: 106540000,
cost: 38.13,
standard_cost: 38.13,
user_cost: 38.13
}
},
seven_day: {
utilization: 100,
resets_at: '2026-03-13T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 211,
tokens: 106540000,
cost: 38.13,
standard_cost: 38.13,
user_cost: 38.13
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2004,
platform: 'openai',
type: 'oauth',
rate_limit_reset_at: '2099-03-07T12:00:00Z',
extra: {
codex_5h_used_percent: 0,
codex_7d_used_percent: 0
}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2004)
expect(wrapper.text()).toContain('5h|100|106540000')
expect(wrapper.text()).toContain('7d|100|106540000')
expect(wrapper.text()).not.toContain('5h|0|')
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import BulkEditAccountModal from '../BulkEditAccountModal.vue'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
bulkEdit: vi.fn()
}
}
}))
vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn()
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function mountModal() {
return mount(BulkEditAccountModal, {
props: {
show: true,
accountIds: [1, 2],
selectedPlatforms: ['antigravity'],
proxies: [],
groups: []
} as any,
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' },
Select: true,
ProxySelector: true,
GroupSelector: true,
Icon: true
}
}
})
}
describe('BulkEditAccountModal', () => {
it('antigravity 白名单包含 Gemini 图片模型且过滤掉普通 GPT 模型', () => {
const wrapper = mountModal()
expect(wrapper.text()).toContain('Gemini 3.1 Flash Image')
expect(wrapper.text()).toContain('Gemini 3 Pro Image (Legacy)')
expect(wrapper.text()).not.toContain('GPT-5.3 Codex')
})
it('antigravity 映射预设包含图片映射并过滤 OpenAI 预设', async () => {
const wrapper = mountModal()
const mappingTab = wrapper.findAll('button').find((btn) => btn.text().includes('admin.accounts.modelMapping'))
expect(mappingTab).toBeTruthy()
await mappingTab!.trigger('click')
expect(wrapper.text()).toContain('Gemini 3.1 Image')
expect(wrapper.text()).toContain('G3 Image→3.1')
expect(wrapper.text()).not.toContain('GPT-5.3 Codex')
})
})

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { applyInterceptWarmup } from '../credentialsBuilder'
describe('applyInterceptWarmup', () => {
it('create + enabled=true: should set intercept_warmup_requests to true', () => {
const creds: Record<string, unknown> = { access_token: 'tok' }
applyInterceptWarmup(creds, true, 'create')
expect(creds.intercept_warmup_requests).toBe(true)
})
it('create + enabled=false: should not add the field', () => {
const creds: Record<string, unknown> = { access_token: 'tok' }
applyInterceptWarmup(creds, false, 'create')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('edit + enabled=true: should set intercept_warmup_requests to true', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyInterceptWarmup(creds, true, 'edit')
expect(creds.intercept_warmup_requests).toBe(true)
})
it('edit + enabled=false + field exists: should delete the field', () => {
const creds: Record<string, unknown> = { api_key: 'sk', intercept_warmup_requests: true }
applyInterceptWarmup(creds, false, 'edit')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('edit + enabled=false + field absent: should not throw', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyInterceptWarmup(creds, false, 'edit')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('should not affect other fields', () => {
const creds: Record<string, unknown> = {
api_key: 'sk',
base_url: 'url',
intercept_warmup_requests: true
}
applyInterceptWarmup(creds, false, 'edit')
expect(creds.api_key).toBe('sk')
expect(creds.base_url).toBe('url')
expect('intercept_warmup_requests' in creds).toBe(false)
})
})

View File

@@ -0,0 +1,11 @@
export function applyInterceptWarmup(
credentials: Record<string, unknown>,
enabled: boolean,
mode: 'create' | 'edit'
): void {
if (enabled) {
credentials.intercept_warmup_requests = true
} else if (mode === 'edit') {
delete credentials.intercept_warmup_requests
}
}

View File

@@ -0,0 +1,13 @@
export { default as CreateAccountModal } from './CreateAccountModal.vue'
export { default as EditAccountModal } from './EditAccountModal.vue'
export { default as BulkEditAccountModal } from './BulkEditAccountModal.vue'
export { default as ReAuthAccountModal } from './ReAuthAccountModal.vue'
export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue'
export { default as AccountStatusIndicator } from './AccountStatusIndicator.vue'
export { default as AccountUsageCell } from './AccountUsageCell.vue'
export { default as UsageProgressBar } from './UsageProgressBar.vue'
export { default as AccountStatsModal } from './AccountStatsModal.vue'
export { default as AccountTestModal } from './AccountTestModal.vue'
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
export { default as TempUnschedStatusModal } from './TempUnschedStatusModal.vue'
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'

View File

@@ -0,0 +1,650 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.errorPassthrough.title')"
width="extra-wide"
@close="$emit('close')"
>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.description') }}
</p>
<button @click="showCreateModal = true" class="btn btn-primary btn-sm">
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.errorPassthrough.createRule') }}
</button>
</div>
<!-- Rules Table -->
<div v-if="loading" class="flex items-center justify-center py-8">
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
</div>
<div v-else-if="rules.length === 0" class="py-8 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
<Icon name="shield" size="lg" class="text-gray-400" />
</div>
<h4 class="mb-1 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.errorPassthrough.noRules') }}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.createFirstRule') }}
</p>
</div>
<div v-else class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 bg-gray-50 dark:bg-dark-700">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.priority') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.name') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.conditions') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.platforms') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.behavior') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.status') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.columns.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
<tr v-for="rule in rules" :key="rule.id" class="hover:bg-gray-50 dark:hover:bg-dark-700">
<td class="whitespace-nowrap px-3 py-2">
<span class="inline-flex h-5 w-5 items-center justify-center rounded bg-gray-100 text-xs font-medium text-gray-700 dark:bg-dark-600 dark:text-gray-300">
{{ rule.priority }}
</span>
</td>
<td class="px-3 py-2">
<div class="font-medium text-gray-900 dark:text-white text-sm">{{ rule.name }}</div>
<div v-if="rule.description" class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate">
{{ rule.description }}
</div>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1 max-w-48">
<span
v-for="code in rule.error_codes.slice(0, 3)"
:key="code"
class="badge badge-danger text-xs"
>
{{ code }}
</span>
<span
v-if="rule.error_codes.length > 3"
class="text-xs text-gray-500"
>
+{{ rule.error_codes.length - 3 }}
</span>
<span
v-for="keyword in rule.keywords.slice(0, 1)"
:key="keyword"
class="badge badge-gray text-xs"
>
"{{ keyword.length > 10 ? keyword.substring(0, 10) + '...' : keyword }}"
</span>
<span
v-if="rule.keywords.length > 1"
class="text-xs text-gray-500"
>
+{{ rule.keywords.length - 1 }}
</span>
</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.matchMode.' + rule.match_mode) }}
</div>
</td>
<td class="px-3 py-2">
<div v-if="rule.platforms.length === 0" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.errorPassthrough.allPlatforms') }}
</div>
<div v-else class="flex flex-wrap gap-1">
<span
v-for="platform in rule.platforms.slice(0, 2)"
:key="platform"
class="badge badge-primary text-xs"
>
{{ platform }}
</span>
<span v-if="rule.platforms.length > 2" class="text-xs text-gray-500">
+{{ rule.platforms.length - 2 }}
</span>
</div>
</td>
<td class="px-3 py-2">
<div class="text-xs space-y-0.5">
<div class="flex items-center gap-1">
<Icon
:name="rule.passthrough_code ? 'checkCircle' : 'xCircle'"
size="xs"
:class="rule.passthrough_code ? 'text-green-500' : 'text-gray-400'"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.errorPassthrough.code') }}:
{{ rule.passthrough_code ? t('admin.errorPassthrough.passthrough') : (rule.response_code || '-') }}
</span>
</div>
<div class="flex items-center gap-1">
<Icon
:name="rule.passthrough_body ? 'checkCircle' : 'xCircle'"
size="xs"
:class="rule.passthrough_body ? 'text-green-500' : 'text-gray-400'"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.errorPassthrough.body') }}:
{{ rule.passthrough_body ? t('admin.errorPassthrough.passthrough') : t('admin.errorPassthrough.custom') }}
</span>
</div>
<div v-if="rule.skip_monitoring" class="flex items-center gap-1">
<Icon
name="checkCircle"
size="xs"
class="text-yellow-500"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.errorPassthrough.skipMonitoring') }}
</span>
</div>
</div>
</td>
<td class="px-3 py-2">
<button
@click="toggleEnabled(rule)"
:class="[
'relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
rule.enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
rule.enabled ? 'translate-x-3' : 'translate-x-0'
]"
/>
</button>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1">
<button
@click="handleEdit(rule)"
class="p-1 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400"
:title="t('common.edit')"
>
<Icon name="edit" size="sm" />
</button>
<button
@click="handleDelete(rule)"
class="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400"
:title="t('common.delete')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="$emit('close')" class="btn btn-secondary">
{{ t('common.close') }}
</button>
</div>
</template>
<!-- Create/Edit Modal -->
<BaseDialog
:show="showCreateModal || showEditModal"
:title="showEditModal ? t('admin.errorPassthrough.editRule') : t('admin.errorPassthrough.createRule')"
width="wide"
@close="closeFormModal"
>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.errorPassthrough.form.name') }}</label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.errorPassthrough.form.namePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.errorPassthrough.form.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="0"
class="input"
/>
<p class="input-hint">{{ t('admin.errorPassthrough.form.priorityHint') }}</p>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.errorPassthrough.form.description') }}</label>
<input
v-model="form.description"
type="text"
class="input"
:placeholder="t('admin.errorPassthrough.form.descriptionPlaceholder')"
/>
</div>
<!-- Match Conditions -->
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.errorPassthrough.form.matchConditions') }}
</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.errorCodes') }}</label>
<input
v-model="errorCodesInput"
type="text"
class="input text-sm"
:placeholder="t('admin.errorPassthrough.form.errorCodesPlaceholder')"
/>
<p class="input-hint text-xs">{{ t('admin.errorPassthrough.form.errorCodesHint') }}</p>
</div>
<div>
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.keywords') }}</label>
<textarea
v-model="keywordsInput"
rows="2"
class="input font-mono text-xs"
:placeholder="t('admin.errorPassthrough.form.keywordsPlaceholder')"
/>
<p class="input-hint text-xs">{{ t('admin.errorPassthrough.form.keywordsHint') }}</p>
</div>
</div>
<div class="mt-3">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.matchMode') }}</label>
<div class="mt-1 space-y-2">
<label
v-for="option in matchModeOptions"
:key="option.value"
class="flex items-start gap-2 cursor-pointer"
>
<input
type="radio"
:value="option.value"
v-model="form.match_mode"
class="mt-0.5 h-3.5 w-3.5 border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<div class="flex-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ option.label }}</span>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ option.description }}</p>
</div>
</label>
</div>
</div>
<div class="mt-3">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.platforms') }}</label>
<div class="flex flex-wrap gap-3">
<label
v-for="platform in platformOptions"
:key="platform.value"
class="inline-flex items-center gap-1.5"
>
<input
type="checkbox"
:value="platform.value"
v-model="form.platforms"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs text-gray-700 dark:text-gray-300">{{ platform.label }}</span>
</label>
</div>
<p class="input-hint text-xs mt-1">{{ t('admin.errorPassthrough.form.platformsHint') }}</p>
</div>
</div>
<!-- Response Behavior -->
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.errorPassthrough.form.responseBehavior') }}
</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.passthrough_code"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.passthroughCode') }}
</span>
</label>
<div v-if="!form.passthrough_code" class="mt-2">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.responseCode') }}</label>
<input
v-model.number="form.response_code"
type="number"
min="100"
max="599"
class="input text-sm"
placeholder="422"
/>
</div>
</div>
<div>
<label class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.passthrough_body"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.passthroughBody') }}
</span>
</label>
<div v-if="!form.passthrough_body" class="mt-2">
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.customMessage') }}</label>
<input
v-model="form.custom_message"
type="text"
class="input text-sm"
:placeholder="t('admin.errorPassthrough.form.customMessagePlaceholder')"
/>
</div>
</div>
</div>
</div>
<!-- Skip Monitoring -->
<div class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.skip_monitoring"
class="h-3.5 w-3.5 rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.skipMonitoring') }}
</span>
</div>
<p class="input-hint text-xs -mt-3">{{ t('admin.errorPassthrough.form.skipMonitoringHint') }}</p>
<!-- Enabled -->
<div class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.enabled"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.enabled') }}
</span>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeFormModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button @click="handleSubmit" :disabled="submitting" class="btn btn-primary">
<Icon v-if="submitting" name="refresh" size="sm" class="mr-1 animate-spin" />
{{ showEditModal ? t('common.update') : t('common.create') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.errorPassthrough.deleteRule')"
:message="t('admin.errorPassthrough.deleteConfirm', { name: deletingRule?.name })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { ErrorPassthroughRule } from '@/api/admin/errorPassthrough'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void emit // suppress unused warning - emit is used via $emit in template
const { t } = useI18n()
const appStore = useAppStore()
const rules = ref<ErrorPassthroughRule[]>([])
const loading = ref(false)
const submitting = ref(false)
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const editingRule = ref<ErrorPassthroughRule | null>(null)
const deletingRule = ref<ErrorPassthroughRule | null>(null)
// Form inputs for arrays
const errorCodesInput = ref('')
const keywordsInput = ref('')
const form = reactive({
name: '',
enabled: true,
priority: 0,
match_mode: 'any' as 'any' | 'all',
platforms: [] as string[],
passthrough_code: true,
response_code: null as number | null,
passthrough_body: true,
custom_message: null as string | null,
skip_monitoring: false,
description: null as string | null
})
const matchModeOptions = computed(() => [
{ value: 'any', label: t('admin.errorPassthrough.matchMode.any'), description: t('admin.errorPassthrough.matchMode.anyHint') },
{ value: 'all', label: t('admin.errorPassthrough.matchMode.all'), description: t('admin.errorPassthrough.matchMode.allHint') }
])
const platformOptions = [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' }
]
// Load rules when dialog opens
watch(() => props.show, (newVal) => {
if (newVal) {
loadRules()
}
})
const loadRules = async () => {
loading.value = true
try {
rules.value = await adminAPI.errorPassthrough.list()
} catch (error) {
appStore.showError(t('admin.errorPassthrough.failedToLoad'))
console.error('Error loading rules:', error)
} finally {
loading.value = false
}
}
const resetForm = () => {
form.name = ''
form.enabled = true
form.priority = 0
form.match_mode = 'any'
form.platforms = []
form.passthrough_code = true
form.response_code = null
form.passthrough_body = true
form.custom_message = null
form.skip_monitoring = false
form.description = null
errorCodesInput.value = ''
keywordsInput.value = ''
}
const closeFormModal = () => {
showCreateModal.value = false
showEditModal.value = false
editingRule.value = null
resetForm()
}
const handleEdit = (rule: ErrorPassthroughRule) => {
editingRule.value = rule
form.name = rule.name
form.enabled = rule.enabled
form.priority = rule.priority
form.match_mode = rule.match_mode
form.platforms = [...rule.platforms]
form.passthrough_code = rule.passthrough_code
form.response_code = rule.response_code
form.passthrough_body = rule.passthrough_body
form.custom_message = rule.custom_message
form.skip_monitoring = rule.skip_monitoring
form.description = rule.description
errorCodesInput.value = rule.error_codes.join(', ')
keywordsInput.value = rule.keywords.join('\n')
showEditModal.value = true
}
const handleDelete = (rule: ErrorPassthroughRule) => {
deletingRule.value = rule
showDeleteDialog.value = true
}
const parseErrorCodes = (): number[] => {
if (!errorCodesInput.value.trim()) return []
return errorCodesInput.value
.split(/[,\s]+/)
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n > 0)
}
const parseKeywords = (): string[] => {
if (!keywordsInput.value.trim()) return []
return keywordsInput.value
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0)
}
const handleSubmit = async () => {
if (!form.name.trim()) {
appStore.showError(t('admin.errorPassthrough.nameRequired'))
return
}
const errorCodes = parseErrorCodes()
const keywords = parseKeywords()
if (errorCodes.length === 0 && keywords.length === 0) {
appStore.showError(t('admin.errorPassthrough.conditionsRequired'))
return
}
submitting.value = true
try {
const data = {
name: form.name.trim(),
enabled: form.enabled,
priority: form.priority,
error_codes: errorCodes,
keywords: keywords,
match_mode: form.match_mode,
platforms: form.platforms,
passthrough_code: form.passthrough_code,
response_code: form.passthrough_code ? null : form.response_code,
passthrough_body: form.passthrough_body,
custom_message: form.passthrough_body ? null : form.custom_message,
skip_monitoring: form.skip_monitoring,
description: form.description?.trim() || null
}
if (showEditModal.value && editingRule.value) {
await adminAPI.errorPassthrough.update(editingRule.value.id, data)
appStore.showSuccess(t('admin.errorPassthrough.ruleUpdated'))
} else {
await adminAPI.errorPassthrough.create(data)
appStore.showSuccess(t('admin.errorPassthrough.ruleCreated'))
}
closeFormModal()
loadRules()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToSave'))
console.error('Error saving rule:', error)
} finally {
submitting.value = false
}
}
const toggleEnabled = async (rule: ErrorPassthroughRule) => {
try {
await adminAPI.errorPassthrough.toggleEnabled(rule.id, !rule.enabled)
rule.enabled = !rule.enabled
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToToggle'))
console.error('Error toggling rule:', error)
}
}
const confirmDelete = async () => {
if (!deletingRule.value) return
try {
await adminAPI.errorPassthrough.delete(deletingRule.value.id)
appStore.showSuccess(t('admin.errorPassthrough.ruleDeleted'))
showDeleteDialog.value = false
deletingRule.value = null
loadRules()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToDelete'))
console.error('Error deleting rule:', error)
}
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<Teleport to="body">
<div v-if="show && position">
<!-- Backdrop: click anywhere outside to close -->
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
<div
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style="{ top: position.top + 'px', left: position.left + 'px' }"
@click.stop
>
<div class="py-1">
<template v-if="account">
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
{{ t('admin.accounts.testConnection') }}
</button>
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="chart" size="sm" class="text-indigo-500" />
{{ t('admin.accounts.viewStats') }}
</button>
<button @click="$emit('schedule', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="clock" size="sm" class="text-orange-500" />
{{ t('admin.scheduledTests.schedule') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<div v-if="hasRecoverableState" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="hasRecoverableState" @click="$emit('recover-state', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-emerald-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sync" size="sm" />
{{ t('admin.accounts.recoverState') }}
</button>
<button v-if="hasQuotaLimit" @click="$emit('reset-quota', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-teal-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
{{ t('admin.accounts.resetQuota') }}
</button>
</template>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota'])
const { t } = useI18n()
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
return true
}
const modelLimits = (props.account?.extra as Record<string, unknown> | undefined)?.model_rate_limits as
| Record<string, { rate_limit_reset_at: string }>
| undefined
if (modelLimits) {
const now = new Date()
return Object.values(modelLimits).some(info => new Date(info.rate_limit_reset_at) > now)
}
return false
})
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
const isTempUnschedulable = computed(() => props.account?.temp_unschedulable_until && new Date(props.account.temp_unschedulable_until) > new Date())
const hasRecoverableState = computed(() => {
return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value)
})
const hasQuotaLimit = computed(() => {
return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && (
(props.account?.quota_limit ?? 0) > 0 ||
(props.account?.quota_daily_limit ?? 0) > 0 ||
(props.account?.quota_weekly_limit ?? 0) > 0
)
})
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') emit('close')
}
watch(
() => props.show,
(visible) => {
if (visible) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
},
{ immediate: true }
)
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg dark:bg-primary-900/20">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}
</span>
<button
@click="$emit('select-page')"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
</button>
<span class="text-gray-300 dark:text-primary-800"></span>
<button
@click="$emit('clear')"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.clear') }}
</button>
</div>
<div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,713 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.usageStatistics')"
width="extra-wide"
@close="handleClose"
>
<div class="space-y-6">
<!-- Account Info Header -->
<div
v-if="account"
class="flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<Icon name="chartBar" size="md" class="text-white" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.last30DaysUsage') }}
</div>
</div>
</div>
<span
:class="[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<template v-else-if="stats">
<!-- Row 1: Main Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- 30-Day Total Cost -->
<div
class="card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalCost')
}}</span>
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
<Icon name="dollar" size="sm" class="text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.total_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost)
}})
</span>
</p>
</div>
<!-- 30-Day Total Requests -->
<div
class="card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalRequests')
}}</span>
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
<Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ formatNumber(stats.summary.total_requests) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.totalCalls') }}
</p>
</div>
<!-- Daily Average Cost -->
<div
class="card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgDailyCost')
}}</span>
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
<Icon
name="calculator"
size="sm"
class="text-amber-600 dark:text-amber-400"
/>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used
})
}}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p>
</div>
<!-- Daily Average Requests -->
<div
class="card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgDailyRequests')
}}</span>
<div class="rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30">
<svg
class="h-4 w-4 text-purple-600 dark:text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.avgDailyUsage') }}
</p>
</div>
</div>
<!-- Row 2: Today, Highest Cost, Highest Requests -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- Today Overview -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
<Icon name="clock" size="sm" class="text-cyan-600 dark:text-cyan-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.todayOverview')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.today?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.tokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0)
}}</span>
</div>
</div>
</div>
<!-- Highest Cost Day -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
<Icon name="fire" size="sm" class="text-orange-600 dark:text-orange-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestCostDay')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.date')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
stats.summary.highest_cost_day?.label || '-'
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.highest_cost_day?.requests || 0)
}}</span>
</div>
</div>
</div>
<!-- Highest Request Day -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
<Icon
name="trendingUp"
size="sm"
class="text-indigo-600 dark:text-indigo-400"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestRequestDay')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.date')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
stats.summary.highest_request_day?.label || '-'
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{
formatNumber(stats.summary.highest_request_day?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div>
</div>
</div>
<!-- Row 3: Token Stats -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- Accumulated Tokens -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
<Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.accumulatedTokens')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.total_tokens)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.dailyAvgTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(Math.round(stats.summary.avg_daily_tokens))
}}</span>
</div>
</div>
</div>
<!-- Performance -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
<Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.performance')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgResponseTime')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatDuration(stats.summary.avg_duration_ms)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.daysActive')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span
>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
<Icon
name="clipboard"
size="sm"
class="text-lime-600 dark:text-lime-400"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.recentActivity')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayRequests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.today?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayCost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
</div>
</div>
</div>
<!-- Usage Trend Chart -->
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.stats.usageTrend') }}
</h3>
<div class="h-64">
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</div>
<!-- Model Distribution -->
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
<EndpointDistributionChart
:endpoint-stats="stats.endpoints || []"
:loading="false"
:title="t('usage.inboundEndpoint')"
/>
<EndpointDistributionChart
:endpoint-stats="stats.upstream_endpoints || []"
:loading="false"
:title="t('usage.upstreamEndpoint')"
/>
</template>
<!-- No Data State -->
<div
v-else-if="!loading"
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
>
<Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" />
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const { t } = useI18n()
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const loading = ref(false)
const stats = ref<AccountUsageStatsResponse | null>(null)
// Dark mode detection
const isDarkMode = computed(() => {
return document.documentElement.classList.contains('dark')
})
// Chart colors
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
}))
// Line chart data
const trendChartData = computed(() => {
if (!stats.value?.history?.length) return null
return {
labels: stats.value.history.map((h) => h.label),
datasets: [
{
label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{
label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests),
borderColor: '#f97316',
backgroundColor: 'rgba(249, 115, 22, 0.1)',
fill: false,
tension: 0.3,
yAxisID: 'y1'
}
]
}
})
// Line chart options with dual Y-axis
const lineChartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const
},
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.value.text,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 11
}
}
},
tooltip: {
callbacks: {
label: (context: any) => {
const label = context.dataset.label || ''
const value = context.raw
if (label.includes('USD')) {
return `${label}: $${formatCost(value)}`
}
return `${label}: ${formatNumber(value)}`
}
}
}
},
scales: {
x: {
grid: {
color: chartColors.value.grid
},
ticks: {
color: chartColors.value.text,
font: {
size: 10
},
maxRotation: 45,
minRotation: 0
}
},
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
grid: {
color: chartColors.value.grid
},
ticks: {
color: '#3b82f6',
font: {
size: 10
},
callback: (value: string | number) => '$' + formatCost(Number(value))
},
title: {
display: true,
text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6',
font: {
size: 11
}
}
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
grid: {
drawOnChartArea: false
},
ticks: {
color: '#f97316',
font: {
size: 10
},
callback: (value: string | number) => formatNumber(Number(value))
},
title: {
display: true,
text: t('admin.accounts.stats.requests'),
color: '#f97316',
font: {
size: 11
}
}
}
}
}))
// Load stats when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
await loadStats()
} else {
stats.value = null
}
}
)
const loadStats = async () => {
if (!props.account) return
loading.value = true
try {
stats.value = await adminAPI.accounts.getStats(props.account.id, 30)
} catch (error) {
console.error('Failed to load account stats:', error)
stats.value = null
} finally {
loading.value = false
}
}
const handleClose = () => {
emit('close')
}
// Format helpers
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
const formatNumber = (value: number): string => {
if (value >= 1_000_000) {
return (value / 1_000_000).toFixed(2) + 'M'
} else if (value >= 1_000) {
return (value / 1_000).toFixed(2) + 'K'
}
return value.toLocaleString()
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatDuration = (ms: number): string => {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(2)}s`
}
return `${Math.round(ms)}ms`
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<slot name="before"></slot>
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
</button>
<slot name="after"></slot>
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
<slot name="beforeCreate"></slot>
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
<slot name="afterCreate"></slot>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
defineProps(['loading'])
defineEmits(['refresh', 'sync', 'create'])
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<SearchInput
:model-value="searchQuery"
:placeholder="t('admin.accounts.searchAccounts')"
class="w-full sm:w-64"
@update:model-value="$emit('update:searchQuery', $event)"
@search="$emit('change')"
/>
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
<Select :model-value="filters.group" class="w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
import type { AdminGroup } from '@/types'
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
</script>

View File

@@ -0,0 +1,521 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.testAccountConnection')"
width="normal"
@close="handleClose"
>
<div class="space-y-4">
<!-- Account Info Card -->
<div
v-if="account"
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<Icon name="play" size="md" class="text-white" :stroke-width="2" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{ account.type }}
</span>
<span>{{ t('admin.accounts.account') }}</span>
</div>
</div>
</div>
<span
:class="[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<div v-if="!isSoraAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<Select
v-model="selectedModelId"
:options="availableModels"
:disabled="loadingModels || status === 'connecting'"
value-key="id"
label-key="display_name"
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<div
v-else
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{ t('admin.accounts.soraTestHint') }}
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
</div>
<!-- Terminal Output -->
<div class="group relative">
<div
ref="terminalRef"
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
<Icon name="play" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
<Icon name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
<span>{{ t('admin.accounts.connectingToApi') }}</span>
</div>
<!-- Output Lines -->
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
{{ line.text }}
</div>
<!-- Streaming Content -->
<div v-if="streamingContent" class="text-green-400">
{{ streamingContent }}<span class="animate-pulse">_</span>
</div>
<!-- Result Status -->
<div
v-if="status === 'success'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<Icon name="check" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.testCompleted') }}</span>
</div>
<div
v-else-if="status === 'error'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<Icon name="x" size="sm" :stroke-width="2" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if="outputLines.length > 0"
@click="copyOutput"
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<Icon name="link" size="sm" :stroke-width="2" />
</button>
</div>
<div v-if="generatedImages.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.geminiImagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="(image, index) in generatedImages"
:key="`${image.url}-${index}`"
:href="image.url"
target="_blank"
rel="noopener noreferrer"
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
>
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<Icon name="grid" size="sm" :stroke-width="2" />
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
isSoraAccount
? t('admin.accounts.soraTestMode')
: supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
<button
@click="startTest"
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || (!isSoraAccount && !selectedModelId)
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<Icon
v-if="status === 'connecting'"
name="refresh"
size="sm"
class="animate-spin"
:stroke-width="2"
/>
<Icon v-else-if="status === 'idle'" name="play" size="sm" :stroke-width="2" />
<Icon v-else name="refresh" size="sm" :stroke-width="2" />
<span>
{{
status === 'connecting'
? t('admin.accounts.testing')
: status === 'idle'
? t('admin.accounts.startTest')
: t('admin.accounts.retry')
}}
</span>
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import TextArea from '@/components/common/TextArea.vue'
import { Icon } from '@/components/icons'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types'
const { t } = useI18n()
const { copyToClipboard } = useClipboard()
interface OutputLine {
text: string
class: string
}
interface PreviewImage {
url: string
mimeType?: string
}
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const terminalRef = ref<HTMLElement | null>(null)
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
const outputLines = ref<OutputLine[]>([])
const streamingContent = ref('')
const errorMessage = ref('')
const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
const isSoraAccount = computed(() => props.account?.platform === 'sora')
const generatedImages = ref<PreviewImage[]>([])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
if (isSoraAccount.value) return false
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
return [...models].sort((a, b) => {
const aPriority = priorityMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bPriority = priorityMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (aPriority !== bPriority) return aPriority - bPriority
return 0
})
}
// Load available models when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
testPrompt.value = ''
resetState()
await loadAvailableModels()
} else {
closeEventSource()
}
}
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
}
})
const loadAvailableModels = async () => {
if (!props.account) return
if (props.account.platform === 'sora') {
availableModels.value = []
selectedModelId.value = ''
loadingModels.value = false
return
}
loadingModels.value = true
selectedModelId.value = '' // Reset selection before loading
try {
const models = await adminAPI.accounts.getAvailableModels(props.account.id)
availableModels.value = props.account.platform === 'gemini' || props.account.platform === 'antigravity'
? sortTestModels(models)
: models
// Default selection by platform
if (availableModels.value.length > 0) {
if (props.account.platform === 'gemini') {
selectedModelId.value = availableModels.value[0].id
} else {
// Try to select Sonnet as default, otherwise use first model
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
}
}
} catch (error) {
console.error('Failed to load available models:', error)
// Fallback to empty list
availableModels.value = []
selectedModelId.value = ''
} finally {
loadingModels.value = false
}
}
const resetState = () => {
status.value = 'idle'
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
}
const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const addLine = (text: string, className: string = 'text-gray-300') => {
outputLines.value.push({ text, class: className })
scrollToBottom()
}
const scrollToBottom = async () => {
await nextTick()
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
}
const startTest = async () => {
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
try {
// Create EventSource for SSE
const url = `/api/v1/admin/accounts/${props.account.id}/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(
isSoraAccount.value
? {}
: {
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
}
)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr) {
try {
const event = JSON.parse(jsonStr)
handleEvent(event)
} catch (e) {
console.error('Failed to parse SSE event:', e)
}
}
}
}
}
} catch (error: any) {
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
}
}
const handleEvent = (event: {
type: string
text?: string
model?: string
success?: boolean
error?: string
image_url?: string
mime_type?: string
}) => {
switch (event.type) {
case 'test_start':
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
isSoraAccount.value
? t('admin.accounts.soraTestingFlow')
: supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400')
break
case 'content':
if (event.text) {
streamingContent.value += event.text
scrollToBottom()
}
break
case 'image':
if (event.image_url) {
generatedImages.value.push({
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
if (event.success) {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
break
}
}
const copyOutput = () => {
const text = outputLines.value.map((l) => l.text).join('\n')
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>

View File

@@ -0,0 +1,205 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.dataImportTitle')"
width="normal"
close-on-click-outside
@close="handleClose"
>
<form id="import-data-form" class="space-y-4" @submit.prevent="handleImport">
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.dataImportHint') }}
</div>
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('admin.accounts.dataImportWarning') }}
</div>
<div>
<label class="input-label">{{ t('admin.accounts.dataImportFile') }}</label>
<div
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
>
<div class="min-w-0">
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
</div>
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
</div>
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
{{ t('common.chooseFile') }}
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
accept="application/json,.json"
@change="handleFileChange"
/>
</div>
<div
v-if="result"
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.dataImportResult') }}
</div>
<div class="text-sm text-gray-700 dark:text-dark-300">
{{ t('admin.accounts.dataImportResultSummary', result) }}
</div>
<div v-if="errorItems.length" class="mt-2">
<div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.accounts.dataImportErrors') }}
</div>
<div
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} {{ item.message }}
</div>
</div>
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="import-data-form"
:disabled="importing"
>
{{ importing ? t('admin.accounts.dataImporting') : t('admin.accounts.dataImportButton') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
import type { AdminDataImportResult } from '@/types'
interface Props {
show: boolean
}
interface Emits {
(e: 'close'): void
(e: 'imported'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
const importing = ref(false)
const file = ref<File | null>(null)
const result = ref<AdminDataImportResult | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const fileName = computed(() => file.value?.name || '')
const errorItems = computed(() => result.value?.errors || [])
watch(
() => props.show,
(open) => {
if (open) {
file.value = null
result.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
)
const openFilePicker = () => {
fileInput.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
file.value = target.files?.[0] || null
}
const handleClose = () => {
if (importing.value) return
emit('close')
}
const readFileAsText = async (sourceFile: File): Promise<string> => {
if (typeof sourceFile.text === 'function') {
return sourceFile.text()
}
if (typeof sourceFile.arrayBuffer === 'function') {
const buffer = await sourceFile.arrayBuffer()
return new TextDecoder().decode(buffer)
}
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.readAsText(sourceFile)
})
}
const handleImport = async () => {
if (!file.value) {
appStore.showError(t('admin.accounts.dataImportSelectFile'))
return
}
importing.value = true
try {
const text = await readFileAsText(file.value)
const dataPayload = JSON.parse(text)
const res = await adminAPI.accounts.importData({
data: dataPayload,
skip_default_group_bind: true
})
result.value = res
const msgParams: Record<string, unknown> = {
account_created: res.account_created,
account_failed: res.account_failed,
proxy_created: res.proxy_created,
proxy_reused: res.proxy_reused,
proxy_failed: res.proxy_failed,
}
if (res.account_failed > 0 || res.proxy_failed > 0) {
appStore.showError(t('admin.accounts.dataImportCompletedWithErrors', msgParams))
} else {
appStore.showSuccess(t('admin.accounts.dataImportSuccess', msgParams))
emit('imported')
}
} catch (error: any) {
if (error instanceof SyntaxError) {
appStore.showError(t('admin.accounts.dataImportParseFailed'))
} else {
appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
}
} finally {
importing.value = false
}
}
</script>

View File

@@ -0,0 +1,548 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.reAuthorizeAccount')"
width="normal"
@close="handleClose"
>
<div v-if="account" class="space-y-4">
<!-- Account Info -->
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAILike
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: isAntigravity
? 'from-purple-500 to-purple-600'
: 'from-orange-500 to-orange-600'
]"
>
<Icon name="sparkles" size="md" class="text-white" />
</div>
<div>
<span class="block font-semibold text-gray-900 dark:text-white">{{
account.name
}}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isSora
? t('admin.accounts.soraAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: isAntigravity
? t('admin.accounts.antigravityAccount')
: t('admin.accounts.claudeCodeAccount')
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection (Claude only) -->
<fieldset v-if="isAnthropic" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.types.oauth')
}}</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.setupTokenLongLived')
}}</span>
</label>
</div>
</fieldset>
<!-- Gemini OAuth Type Display (read-only) -->
<div v-if="isGemini" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}
</div>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-amber-500 text-white'
]"
>
<Icon v-if="geminiOAuthType === 'google_one'" name="user" size="sm" />
<Icon v-else-if="geminiOAuthType === 'code_assist'" name="cloud" size="sm" />
<Icon v-else name="sparkles" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{
geminiOAuthType === 'google_one'
? 'Google One'
: geminiOAuthType === 'code_assist'
? t('admin.accounts.gemini.oauthType.builtInTitle')
: t('admin.accounts.gemini.oauthType.customTitle')
}}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{
geminiOAuthType === 'google_one'
? '个人账号'
: geminiOAuthType === 'code_assist'
? t('admin.accounts.gemini.oauthType.builtInDesc')
: t('admin.accounts.gemini.oauthType.customDesc')
}}
</span>
</div>
</div>
</div>
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentLoading"
:error="currentError"
:show-help="isAnthropic"
:show-proxy-warning="isAnthropic"
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
</div>
<template #footer>
<div v-if="account" class="flex justify-between gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
v-if="isManualInputMethod"
type="button"
:disabled="!canExchangeCode"
class="btn btn-primary"
@click="handleExchangeCode"
>
<svg
v-if="currentLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
currentLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import {
useAccountOAuth,
type AddMethod,
type AuthInputMethod
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Account } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import OAuthAuthorizationFlow from '@/components/account/OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface OAuthFlowExposed {
authCode: string
oauthState: string
projectId: string
sessionKey: string
inputMethod: AuthInputMethod
reset: () => void
}
interface Props {
show: boolean
account: Account | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
reauthorized: [account: Account]
}>()
const appStore = useAppStore()
const { t } = useI18n()
// OAuth composables
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
// Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isSora = computed(() => props.account?.platform === 'sora')
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
})
// Computed
const isManualInputMethod = computed(() => {
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = currentSessionId.value
const loading = currentLoading.value
return authCode.trim() && sessionId && !loading
})
// Watchers
watch(
() => props.show,
(newVal) => {
if (newVal && props.account) {
// Initialize addMethod based on current account type (Claude only)
if (
isAnthropic.value &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
) {
addMethod.value = props.account.type as AddMethod
}
if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
geminiOAuthType.value =
creds.oauth_type === 'google_one'
? 'google_one'
: creds.oauth_type === 'ai_studio'
? 'ai_studio'
: 'code_assist'
}
} else {
resetState()
}
}
)
// Methods
const resetState = () => {
addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist'
claudeOAuth.resetState()
openaiOAuth.resetState()
soraOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
}
const handleClose = () => {
emit('close')
}
const handleGenerateUrl = async () => {
if (!props.account) return
if (isOpenAILike.value) {
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
} else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
}
const handleExchangeCode = async () => {
if (!props.account) return
const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim()) return
if (isOpenAILike.value) {
// OpenAI OAuth flow
const oauthClient = activeOpenAIOAuth.value
const sessionId = oauthClient.sessionId.value
if (!sessionId) return
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
if (!stateToUse) {
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
return
}
const tokenInfo = await oauthClient.exchangeAuthCode(
authCode.trim(),
sessionId,
stateToUse,
props.account.proxy_id
)
if (!tokenInfo) return
// Build credentials and extra info
const credentials = oauthClient.buildCredentials(tokenInfo)
const extra = oauthClient.buildExtraInfo(tokenInfo)
try {
// Update account with new credentials
await adminAPI.accounts.update(props.account.id, {
type: 'oauth', // OpenAI OAuth is always 'oauth' type
credentials,
extra
})
// Clear error status after successful re-authorization
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized', updatedAccount)
handleClose()
} catch (error: any) {
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauthClient.error.value)
}
} else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || geminiOAuth.state.value
if (!stateToUse) return
const tokenInfo = await geminiOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id,
oauthType: geminiOAuthType.value,
tierId: typeof (props.account.credentials as any)?.tier_id === 'string' ? ((props.account.credentials as any).tier_id as string) : undefined
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized', updatedAccount)
handleClose()
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
}
} else if (isAntigravity.value) {
// Antigravity OAuth flow
const sessionId = antigravityOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || antigravityOAuth.state.value
if (!stateToUse) return
const tokenInfo = await antigravityOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id
})
if (!tokenInfo) return
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized', updatedAccount)
handleClose()
} catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value)
}
} else {
// Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value
if (!sessionId) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId,
code: authCode.trim(),
...proxyConfig
})
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
// Clear error status after successful re-authorization
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized', updatedAccount)
handleClose()
} catch (error: any) {
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(claudeOAuth.error.value)
} finally {
claudeOAuth.loading.value = false
}
}
}
const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAILike.value) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
code: sessionKey.trim(),
...proxyConfig
})
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
// Clear error status after successful re-authorization
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized', updatedAccount)
handleClose()
} catch (error: any) {
claudeOAuth.error.value =
error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
claudeOAuth.loading.value = false
}
}
</script>

View File

@@ -0,0 +1,684 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.scheduledTests.title')"
width="wide"
@close="emit('close')"
>
<div class="space-y-4">
<!-- Add Plan Button -->
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.scheduledTests.title') }}
</p>
<button
@click="showAddForm = !showAddForm"
class="btn btn-primary flex items-center gap-1.5 text-sm"
>
<Icon name="plus" size="sm" :stroke-width="2" />
{{ t('admin.scheduledTests.addPlan') }}
</button>
</div>
<!-- Add Plan Form -->
<div
v-if="showAddForm"
class="rounded-xl border border-primary-200 bg-primary-50/50 p-4 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.scheduledTests.addPlan') }}
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.model') }}
</label>
<Select
v-model="newPlan.model_id"
:options="modelOptions"
:placeholder="t('admin.scheduledTests.model')"
:searchable="modelOptions.length > 5"
/>
</div>
<div>
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.cronExpression') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.cronTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleEvery30Min') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleHourly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleDaily') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleWeekly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="newPlan.cron_expression"
:placeholder="'*/30 * * * *'"
:hint="t('admin.scheduledTests.cronHelp')"
/>
</div>
<div>
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.maxResults') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.maxResultsTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipBody') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipExample') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="newPlan.max_results"
type="number"
placeholder="100"
/>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="newPlan.enabled" />
{{ t('admin.scheduledTests.enabled') }}
</label>
</div>
<div class="flex items-end">
<div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="newPlan.auto_recover" />
{{ t('admin.scheduledTests.autoRecover') }}
</label>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{{ t('admin.scheduledTests.autoRecoverHelp') }}
</p>
</div>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
@click="showAddForm = false; resetNewPlan()"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleCreate"
:disabled="!newPlan.model_id || !newPlan.cron_expression || creating"
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon v-if="creating" name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
{{ t('common.save') }}
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<Icon name="refresh" size="md" class="animate-spin text-gray-400" :stroke-width="2" />
<span class="ml-2 text-sm text-gray-500">{{ t('common.loading') }}...</span>
</div>
<!-- Empty State -->
<div
v-else-if="plans.length === 0"
class="rounded-xl border border-dashed border-gray-300 py-10 text-center dark:border-dark-600"
>
<Icon name="calendar" size="lg" class="mx-auto mb-2 text-gray-400" :stroke-width="1.5" />
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.scheduledTests.noPlans') }}
</p>
</div>
<!-- Plans List -->
<div v-else class="space-y-3">
<div
v-for="plan in plans"
:key="plan.id"
class="rounded-xl border border-gray-200 bg-white transition-all dark:border-dark-600 dark:bg-dark-800"
>
<!-- Plan Header -->
<div
class="flex cursor-pointer items-center justify-between px-4 py-3"
@click="toggleExpand(plan.id)"
>
<div class="flex flex-1 items-center gap-4">
<!-- Model -->
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ plan.model_id }}
</div>
<div class="mt-0.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{{ plan.cron_expression }}
</div>
</div>
<!-- Enabled Toggle -->
<div class="flex items-center gap-1.5" @click.stop>
<Toggle
:model-value="plan.enabled"
@update:model-value="(val: boolean) => handleToggleEnabled(plan, val)"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ plan.enabled ? t('admin.scheduledTests.enabled') : '' }}
</span>
</div>
<!-- Auto Recover Badge -->
<span
v-if="plan.auto_recover"
class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400"
>
{{ t('admin.scheduledTests.autoRecover') }}
</span>
</div>
<div class="flex items-center gap-3">
<!-- Last Run -->
<div v-if="plan.last_run_at" class="hidden text-right text-xs text-gray-500 dark:text-gray-400 sm:block">
<div>{{ t('admin.scheduledTests.lastRun') }}</div>
<div>{{ formatDateTime(plan.last_run_at) }}</div>
</div>
<!-- Next Run -->
<div v-if="plan.next_run_at" class="hidden text-right text-xs text-gray-500 dark:text-gray-400 sm:block">
<div>{{ t('admin.scheduledTests.nextRun') }}</div>
<div>{{ formatDateTime(plan.next_run_at) }}</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1" @click.stop>
<button
@click="startEdit(plan)"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-blue-50 hover:text-blue-500 dark:hover:bg-blue-900/20"
:title="t('admin.scheduledTests.editPlan')"
>
<Icon name="edit" size="sm" :stroke-width="2" />
</button>
<button
@click="confirmDeletePlan(plan)"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
:title="t('admin.scheduledTests.deletePlan')"
>
<Icon name="trash" size="sm" :stroke-width="2" />
</button>
</div>
<!-- Expand indicator -->
<Icon
name="chevronDown"
size="sm"
:class="[
'text-gray-400 transition-transform duration-200',
expandedPlanId === plan.id ? 'rotate-180' : ''
]"
/>
</div>
</div>
<!-- Edit Form -->
<div
v-if="editingPlanId === plan.id"
class="border-t border-blue-100 bg-blue-50/50 px-4 py-3 dark:border-blue-900 dark:bg-blue-900/10"
@click.stop
>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.editPlan') }}
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.model') }}
</label>
<Select
v-model="editForm.model_id"
:options="modelOptions"
:placeholder="t('admin.scheduledTests.model')"
:searchable="modelOptions.length > 5"
/>
</div>
<div>
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.cronExpression') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.cronTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleEvery30Min') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleHourly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleDaily') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipExampleWeekly') }}</p>
<p>{{ t('admin.scheduledTests.cronTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="editForm.cron_expression"
:placeholder="'*/30 * * * *'"
:hint="t('admin.scheduledTests.cronHelp')"
/>
</div>
<div>
<label class="mb-1 flex items-center gap-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.maxResults') }}
<HelpTooltip>
<template #trigger>
<span class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-gray-400/70 text-[10px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-gray-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400">
?
</span>
</template>
<div class="space-y-1.5">
<p class="font-medium">{{ t('admin.scheduledTests.maxResultsTooltipTitle') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipMeaning') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipBody') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipExample') }}</p>
<p>{{ t('admin.scheduledTests.maxResultsTooltipRange') }}</p>
</div>
</HelpTooltip>
</label>
<Input
v-model="editForm.max_results"
type="number"
placeholder="100"
/>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="editForm.enabled" />
{{ t('admin.scheduledTests.enabled') }}
</label>
</div>
<div class="flex items-end">
<div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="editForm.auto_recover" />
{{ t('admin.scheduledTests.autoRecover') }}
</label>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{{ t('admin.scheduledTests.autoRecoverHelp') }}
</p>
</div>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
@click="cancelEdit"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleEdit"
:disabled="!editForm.model_id || !editForm.cron_expression || updating"
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon v-if="updating" name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
{{ t('common.save') }}
</button>
</div>
</div>
<!-- Expanded Results Section -->
<div
v-if="expandedPlanId === plan.id"
class="border-t border-gray-100 px-4 py-3 dark:border-dark-700"
>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.results') }}
</div>
<!-- Results Loading -->
<div v-if="loadingResults" class="flex items-center justify-center py-4">
<Icon name="refresh" size="sm" class="animate-spin text-gray-400" :stroke-width="2" />
<span class="ml-2 text-xs text-gray-500">{{ t('common.loading') }}...</span>
</div>
<!-- No Results -->
<div
v-else-if="results.length === 0"
class="py-4 text-center text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.scheduledTests.noResults') }}
</div>
<!-- Results List -->
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
<div
v-for="result in results"
:key="result.id"
class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Status Badge -->
<span
:class="[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
result.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: result.status === 'running'
? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400'
]"
>
{{
result.status === 'success'
? t('admin.scheduledTests.success')
: result.status === 'running'
? t('admin.scheduledTests.running')
: t('admin.scheduledTests.failed')
}}
</span>
<!-- Latency -->
<span v-if="result.latency_ms > 0" class="text-xs text-gray-500 dark:text-gray-400">
{{ result.latency_ms }}ms
</span>
</div>
<!-- Started At -->
<span class="text-xs text-gray-400">
{{ formatDateTime(result.started_at) }}
</span>
</div>
<!-- Response / Error (collapsible) -->
<div v-if="result.error_message" class="mt-2">
<div
class="cursor-pointer text-xs font-medium text-red-600 dark:text-red-400"
@click="toggleResultDetail(result.id)"
>
{{ t('admin.scheduledTests.errorMessage') }}
<Icon
name="chevronDown"
size="sm"
:class="[
'inline transition-transform duration-200',
expandedResultIds.has(result.id) ? 'rotate-180' : ''
]"
/>
</div>
<pre
v-if="expandedResultIds.has(result.id)"
class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-red-50 p-2 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300"
>{{ result.error_message }}</pre>
</div>
<div v-else-if="result.response_text" class="mt-2">
<div
class="cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-400"
@click="toggleResultDetail(result.id)"
>
{{ t('admin.scheduledTests.responseText') }}
<Icon
name="chevronDown"
size="sm"
:class="[
'inline transition-transform duration-200',
expandedResultIds.has(result.id) ? 'rotate-180' : ''
]"
/>
</div>
<pre
v-if="expandedResultIds.has(result.id)"
class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-gray-100 p-2 text-xs text-gray-700 dark:bg-dark-800 dark:text-gray-300"
>{{ result.response_text }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteConfirm"
:title="t('admin.scheduledTests.deletePlan')"
:message="t('admin.scheduledTests.confirmDelete')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleDelete"
@cancel="showDeleteConfirm = false"
/>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import Input from '@/components/common/Input.vue'
import Toggle from '@/components/common/Toggle.vue'
import { Icon } from '@/components/icons'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format'
import type { ScheduledTestPlan, ScheduledTestResult } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
const props = defineProps<{
show: boolean
accountId: number | null
modelOptions: SelectOption[]
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
// State
const loading = ref(false)
const creating = ref(false)
const loadingResults = ref(false)
const plans = ref<ScheduledTestPlan[]>([])
const results = ref<ScheduledTestResult[]>([])
const expandedPlanId = ref<number | null>(null)
const expandedResultIds = reactive(new Set<number>())
const showAddForm = ref(false)
const showDeleteConfirm = ref(false)
const deletingPlan = ref<ScheduledTestPlan | null>(null)
const editingPlanId = ref<number | null>(null)
const updating = ref(false)
const editForm = reactive({
model_id: '' as string,
cron_expression: '' as string,
max_results: '100' as string,
enabled: true,
auto_recover: false
})
const newPlan = reactive({
model_id: '' as string,
cron_expression: '' as string,
max_results: '100' as string,
enabled: true,
auto_recover: false
})
const resetNewPlan = () => {
newPlan.model_id = ''
newPlan.cron_expression = ''
newPlan.max_results = '100'
newPlan.enabled = true
newPlan.auto_recover = false
}
// Load plans when dialog opens
watch(
() => props.show,
async (visible) => {
if (visible && props.accountId) {
await loadPlans()
} else {
plans.value = []
results.value = []
expandedPlanId.value = null
expandedResultIds.clear()
showAddForm.value = false
showDeleteConfirm.value = false
}
}
)
const loadPlans = async () => {
if (!props.accountId) return
loading.value = true
try {
plans.value = await adminAPI.scheduledTests.listByAccount(props.accountId)
} catch (error: any) {
appStore.showError(error?.message || 'Failed to load plans')
} finally {
loading.value = false
}
}
const handleCreate = async () => {
if (!props.accountId || !newPlan.model_id || !newPlan.cron_expression) return
creating.value = true
try {
const maxResults = Number(newPlan.max_results) || 100
await adminAPI.scheduledTests.create({
account_id: props.accountId,
model_id: newPlan.model_id,
cron_expression: newPlan.cron_expression,
enabled: newPlan.enabled,
max_results: maxResults,
auto_recover: newPlan.auto_recover
})
appStore.showSuccess(t('admin.scheduledTests.createSuccess'))
showAddForm.value = false
resetNewPlan()
await loadPlans()
} catch (error: any) {
appStore.showError(error?.message || 'Failed to create plan')
} finally {
creating.value = false
}
}
const handleToggleEnabled = async (plan: ScheduledTestPlan, enabled: boolean) => {
try {
const updated = await adminAPI.scheduledTests.update(plan.id, { enabled })
const index = plans.value.findIndex((p) => p.id === plan.id)
if (index !== -1) {
plans.value[index] = updated
}
appStore.showSuccess(t('admin.scheduledTests.updateSuccess'))
} catch (error: any) {
appStore.showError(error?.message || 'Failed to update plan')
}
}
const startEdit = (plan: ScheduledTestPlan) => {
editingPlanId.value = plan.id
editForm.model_id = plan.model_id
editForm.cron_expression = plan.cron_expression
editForm.max_results = String(plan.max_results)
editForm.enabled = plan.enabled
editForm.auto_recover = plan.auto_recover
}
const cancelEdit = () => {
editingPlanId.value = null
}
const handleEdit = async () => {
if (!editingPlanId.value || !editForm.model_id || !editForm.cron_expression) return
updating.value = true
try {
const updated = await adminAPI.scheduledTests.update(editingPlanId.value, {
model_id: editForm.model_id,
cron_expression: editForm.cron_expression,
max_results: Number(editForm.max_results) || 100,
enabled: editForm.enabled,
auto_recover: editForm.auto_recover
})
const index = plans.value.findIndex((p) => p.id === editingPlanId.value)
if (index !== -1) {
plans.value[index] = updated
}
appStore.showSuccess(t('admin.scheduledTests.updateSuccess'))
editingPlanId.value = null
} catch (error: any) {
appStore.showError(error?.message || 'Failed to update plan')
} finally {
updating.value = false
}
}
const confirmDeletePlan = (plan: ScheduledTestPlan) => {
deletingPlan.value = plan
showDeleteConfirm.value = true
}
const handleDelete = async () => {
if (!deletingPlan.value) return
try {
await adminAPI.scheduledTests.delete(deletingPlan.value.id)
appStore.showSuccess(t('admin.scheduledTests.deleteSuccess'))
plans.value = plans.value.filter((p) => p.id !== deletingPlan.value!.id)
if (expandedPlanId.value === deletingPlan.value.id) {
expandedPlanId.value = null
results.value = []
}
} catch (error: any) {
appStore.showError(error?.message || 'Failed to delete plan')
} finally {
showDeleteConfirm.value = false
deletingPlan.value = null
}
}
const toggleExpand = async (planId: number) => {
if (expandedPlanId.value === planId) {
expandedPlanId.value = null
results.value = []
expandedResultIds.clear()
return
}
expandedPlanId.value = planId
expandedResultIds.clear()
loadingResults.value = true
try {
results.value = await adminAPI.scheduledTests.listResults(planId, 20)
} catch (error: any) {
appStore.showError(error?.message || 'Failed to load results')
results.value = []
} finally {
loadingResults.value = false
}
}
const toggleResultDetail = (resultId: number) => {
if (expandedResultIds.has(resultId)) {
expandedResultIds.delete(resultId)
} else {
expandedResultIds.add(resultId)
}
}
</script>

View File

@@ -0,0 +1,147 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AccountTestModal from '../AccountTestModal.vue'
const { getAvailableModels, copyToClipboard } = vi.hoisted(() => ({
getAvailableModels: vi.fn(),
copyToClipboard: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
getAvailableModels
}
}
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
const messages: Record<string, string> = {
'admin.accounts.geminiImagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
}
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string | number>) => {
if (key === 'admin.accounts.geminiImageReceived' && params?.count) {
return `received-${params.count}`
}
return messages[key] || key
}
})
}
})
function createStreamResponse(lines: string[]) {
const encoder = new TextEncoder()
const chunks = lines.map((line) => encoder.encode(line))
let index = 0
return {
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockImplementation(async () => {
if (index < chunks.length) {
return { done: false, value: chunks[index++] }
}
return { done: true, value: undefined }
})
})
}
} as Response
}
function mountModal() {
return mount(AccountTestModal, {
props: {
show: false,
account: {
id: 42,
name: 'Gemini Image Test',
platform: 'gemini',
type: 'apikey',
status: 'active'
}
} as any,
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' },
Select: { template: '<div class="select-stub"></div>' },
TextArea: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: '<textarea class="textarea-stub" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
Icon: true
}
}
})
}
describe('AccountTestModal', () => {
beforeEach(() => {
getAvailableModels.mockResolvedValue([
{ id: 'gemini-2.0-flash', display_name: 'Gemini 2.0 Flash' },
{ id: 'gemini-2.5-flash-image', display_name: 'Gemini 2.5 Flash Image' },
{ id: 'gemini-3.1-flash-image', display_name: 'Gemini 3.1 Flash Image' }
])
copyToClipboard.mockReset()
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: vi.fn((key: string) => (key === 'auth_token' ? 'test-token' : null)),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
},
configurable: true
})
global.fetch = vi.fn().mockResolvedValue(
createStreamResponse([
'data: {"type":"test_start","model":"gemini-2.5-flash-image"}\n',
'data: {"type":"image","image_url":"data:image/png;base64,QUJD","mime_type":"image/png"}\n',
'data: {"type":"test_complete","success":true}\n'
])
) as any
})
afterEach(() => {
vi.restoreAllMocks()
})
it('gemini 图片模型测试会携带提示词并渲染图片预览', async () => {
const wrapper = mountModal()
await wrapper.setProps({ show: true })
await flushPromises()
const promptInput = wrapper.find('textarea.textarea-stub')
expect(promptInput.exists()).toBe(true)
await promptInput.setValue('draw a tiny orange cat astronaut')
const buttons = wrapper.findAll('button')
const startButton = buttons.find((button) => button.text().includes('admin.accounts.startTest'))
expect(startButton).toBeTruthy()
await startButton!.trigger('click')
await flushPromises()
await flushPromises()
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, request] = (global.fetch as any).mock.calls[0]
expect(JSON.parse(request.body)).toEqual({
model_id: 'gemini-3.1-flash-image',
prompt: 'draw a tiny orange cat astronaut'
})
const preview = wrapper.find('img[alt="gemini-test-image-1"]')
expect(preview.exists()).toBe(true)
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
})
})

View File

@@ -0,0 +1,186 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.announcements.readStatus')"
width="extra-wide"
@close="handleClose"
>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1">
<input
v-model="search"
type="text"
class="input"
:placeholder="t('admin.announcements.searchUsers')"
@input="handleSearch"
/>
</div>
<button @click="load" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
<DataTable :columns="columns" :data="items" :loading="loading">
<template #cell-email="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-balance="{ value }">
<span class="font-medium text-gray-900 dark:text-white">${{ Number(value ?? 0).toFixed(2) }}</span>
</template>
<template #cell-eligible="{ value }">
<span :class="['badge', value ? 'badge-success' : 'badge-gray']">
{{ value ? t('admin.announcements.eligible') : t('common.no') }}
</span>
</template>
<template #cell-read_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? formatDateTime(value) : t('admin.announcements.unread') }}
</span>
</template>
</DataTable>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</div>
<template #footer>
<div class="flex justify-end">
<button type="button" class="btn btn-secondary" @click="handleClose">{{ t('common.close') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { AnnouncementUserReadStatus } from '@/types'
import type { Column } from '@/components/common/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const props = defineProps<{
show: boolean
announcementId: number | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const loading = ref(false)
const search = ref('')
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const items = ref<AnnouncementUserReadStatus[]>([])
const columns = computed<Column[]>(() => [
{ key: 'email', label: t('common.email') },
{ key: 'username', label: t('admin.users.columns.username') },
{ key: 'balance', label: t('common.balance') },
{ key: 'eligible', label: t('admin.announcements.eligible') },
{ key: 'read_at', label: t('admin.announcements.readAt') }
])
let currentController: AbortController | null = null
async function load() {
if (!props.show || !props.announcementId) return
if (currentController) currentController.abort()
currentController = new AbortController()
try {
loading.value = true
const res = await adminAPI.announcements.getReadStatus(
props.announcementId,
pagination.page,
pagination.page_size,
search.value
)
items.value = res.items
pagination.total = res.total
pagination.pages = res.pages
pagination.page = res.page
pagination.page_size = res.page_size
} catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return
console.error('Failed to load read status:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
pagination.page = page
load()
}
function handlePageSizeChange(pageSize: number) {
pagination.page_size = pageSize
pagination.page = 1
load()
}
let searchDebounceTimer: number | null = null
function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
pagination.page = 1
load()
}, 300)
}
function handleClose() {
emit('close')
}
watch(
() => props.show,
(v) => {
if (!v) return
pagination.page = 1
load()
}
)
watch(
() => props.announcementId,
() => {
if (!props.show) return
pagination.page = 1
load()
}
)
onMounted(() => {
// noop
})
</script>

View File

@@ -0,0 +1,408 @@
<template>
<div class="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/50">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.announcements.form.targetingMode') }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ mode === 'all' ? t('admin.announcements.form.targetingAll') : t('admin.announcements.form.targetingCustom') }}
</div>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="announcement-targeting-mode"
value="all"
:checked="mode === 'all'"
@change="setMode('all')"
class="h-4 w-4"
/>
{{ t('admin.announcements.form.targetingAll') }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="announcement-targeting-mode"
value="custom"
:checked="mode === 'custom'"
@change="setMode('custom')"
class="h-4 w-4"
/>
{{ t('admin.announcements.form.targetingCustom') }}
</label>
</div>
</div>
<div v-if="mode === 'custom'" class="mt-4 space-y-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
OR
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-dark-400">
({{ anyOf.length }}/50)
</span>
</div>
<button
type="button"
class="btn btn-secondary"
:disabled="anyOf.length >= 50"
@click="addOrGroup"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.announcements.form.addOrGroup') }}
</button>
</div>
<div v-if="anyOf.length === 0" class="rounded-xl border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400">
{{ t('admin.announcements.form.targetingCustom') }}: {{ t('admin.announcements.form.addOrGroup') }}
</div>
<div
v-for="(group, groupIndex) in anyOf"
:key="groupIndex"
class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.announcements.form.targetingCustom') }} #{{ groupIndex + 1 }}
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-dark-400">AND ({{ (group.all_of?.length || 0) }}/50)</span>
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.announcements.form.addAndCondition') }}
</div>
</div>
<button
type="button"
class="btn btn-secondary"
@click="removeOrGroup(groupIndex)"
>
<Icon name="trash" size="sm" class="mr-1" />
{{ t('common.delete') }}
</button>
</div>
<div class="mt-4 space-y-3">
<div
v-for="(cond, condIndex) in (group.all_of || [])"
:key="condIndex"
class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30"
>
<div class="flex flex-col gap-3 md:flex-row md:items-end">
<div class="w-full md:w-52">
<label class="input-label">{{ t('admin.announcements.form.conditionType') }}</label>
<Select
:model-value="cond.type"
:options="conditionTypeOptions"
@update:model-value="(v) => setConditionType(groupIndex, condIndex, v as any)"
/>
</div>
<div v-if="cond.type === 'subscription'" class="flex-1">
<label class="input-label">{{ t('admin.announcements.form.selectPackages') }}</label>
<GroupSelector
v-model="subscriptionSelections[groupIndex][condIndex]"
:groups="groups"
/>
</div>
<div v-else class="flex flex-1 flex-col gap-3 sm:flex-row">
<div class="w-full sm:w-44">
<label class="input-label">{{ t('admin.announcements.form.operator') }}</label>
<Select
:model-value="cond.operator"
:options="balanceOperatorOptions"
@update:model-value="(v) => setOperator(groupIndex, condIndex, v as any)"
/>
</div>
<div class="w-full sm:flex-1">
<label class="input-label">{{ t('admin.announcements.form.balanceValue') }}</label>
<input
:value="String(cond.value ?? '')"
type="number"
step="any"
class="input"
@input="(e) => setBalanceValue(groupIndex, condIndex, (e.target as HTMLInputElement).value)"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn btn-secondary"
@click="removeAndCondition(groupIndex, condIndex)"
>
<Icon name="trash" size="sm" class="mr-1" />
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn btn-secondary"
:disabled="(group.all_of?.length || 0) >= 50"
@click="addAndCondition(groupIndex)"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.announcements.form.addAndCondition') }}
</button>
</div>
</div>
</div>
<div v-if="validationError" class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-900/10 dark:text-red-300">
{{ validationError }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
AdminGroup,
AnnouncementTargeting,
AnnouncementCondition,
AnnouncementConditionGroup,
AnnouncementConditionType,
AnnouncementOperator
} from '@/types'
import Select from '@/components/common/Select.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const props = defineProps<{
modelValue: AnnouncementTargeting
groups: AdminGroup[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: AnnouncementTargeting): void
}>()
const anyOf = computed(() => props.modelValue?.any_of ?? [])
type Mode = 'all' | 'custom'
const mode = computed<Mode>(() => (anyOf.value.length === 0 ? 'all' : 'custom'))
const conditionTypeOptions = computed(() => [
{ value: 'subscription', label: t('admin.announcements.form.conditionSubscription') },
{ value: 'balance', label: t('admin.announcements.form.conditionBalance') }
])
const balanceOperatorOptions = computed(() => [
{ value: 'gt', label: t('admin.announcements.operators.gt') },
{ value: 'gte', label: t('admin.announcements.operators.gte') },
{ value: 'lt', label: t('admin.announcements.operators.lt') },
{ value: 'lte', label: t('admin.announcements.operators.lte') },
{ value: 'eq', label: t('admin.announcements.operators.eq') }
])
function setMode(next: Mode) {
if (next === 'all') {
emit('update:modelValue', { any_of: [] })
return
}
if (anyOf.value.length === 0) {
emit('update:modelValue', { any_of: [{ all_of: [defaultSubscriptionCondition()] }] })
}
}
function defaultSubscriptionCondition(): AnnouncementCondition {
return {
type: 'subscription' as AnnouncementConditionType,
operator: 'in' as AnnouncementOperator,
group_ids: []
}
}
function defaultBalanceCondition(): AnnouncementCondition {
return {
type: 'balance' as AnnouncementConditionType,
operator: 'gte' as AnnouncementOperator,
value: 0
}
}
type TargetingDraft = {
any_of: AnnouncementConditionGroup[]
}
function updateTargeting(mutator: (draft: TargetingDraft) => void) {
const draft: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
if (!draft.any_of) draft.any_of = []
mutator(draft)
emit('update:modelValue', draft)
}
function addOrGroup() {
updateTargeting((draft) => {
if (draft.any_of.length >= 50) return
draft.any_of.push({ all_of: [defaultSubscriptionCondition()] })
})
}
function removeOrGroup(groupIndex: number) {
updateTargeting((draft) => {
draft.any_of.splice(groupIndex, 1)
})
}
function addAndCondition(groupIndex: number) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group.all_of) group.all_of = []
if (group.all_of.length >= 50) return
group.all_of.push(defaultSubscriptionCondition())
})
}
function removeAndCondition(groupIndex: number, condIndex: number) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
group.all_of.splice(condIndex, 1)
})
}
function setConditionType(groupIndex: number, condIndex: number, nextType: AnnouncementConditionType) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
if (nextType === 'subscription') {
group.all_of[condIndex] = defaultSubscriptionCondition()
} else {
group.all_of[condIndex] = defaultBalanceCondition()
}
})
}
function setOperator(groupIndex: number, condIndex: number, op: AnnouncementOperator) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
const cond = group.all_of[condIndex]
if (!cond) return
cond.operator = op
})
}
function setBalanceValue(groupIndex: number, condIndex: number, raw: string) {
const n = raw === '' ? 0 : Number(raw)
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
const cond = group.all_of[condIndex]
if (!cond) return
cond.value = Number.isFinite(n) ? n : 0
})
}
// We keep group_ids selection in a parallel reactive map because GroupSelector is numeric list.
// Then we mirror it back to targeting.group_ids via a watcher.
const subscriptionSelections = reactive<Record<number, Record<number, number[]>>>({})
function ensureSelectionPath(groupIndex: number, condIndex: number) {
if (!subscriptionSelections[groupIndex]) subscriptionSelections[groupIndex] = {}
if (!subscriptionSelections[groupIndex][condIndex]) subscriptionSelections[groupIndex][condIndex] = []
}
// Sync from modelValue to subscriptionSelections (one-way: model -> local state)
watch(
() => props.modelValue,
(v) => {
const groups = v?.any_of ?? []
for (let gi = 0; gi < groups.length; gi++) {
const allOf = groups[gi]?.all_of ?? []
for (let ci = 0; ci < allOf.length; ci++) {
const c = allOf[ci]
if (c?.type === 'subscription') {
ensureSelectionPath(gi, ci)
// Only update if different to avoid triggering unnecessary updates
const newIds = (c.group_ids ?? []).slice()
const currentIds = subscriptionSelections[gi]?.[ci] ?? []
if (JSON.stringify(newIds.sort()) !== JSON.stringify(currentIds.sort())) {
subscriptionSelections[gi][ci] = newIds
}
}
}
}
},
{ immediate: true }
)
// Sync from subscriptionSelections to modelValue (one-way: local state -> model)
// Use a debounced approach to avoid infinite loops
let syncTimeout: ReturnType<typeof setTimeout> | null = null
watch(
() => subscriptionSelections,
() => {
// Debounce the sync to avoid rapid fire updates
if (syncTimeout) clearTimeout(syncTimeout)
syncTimeout = setTimeout(() => {
// Build the new targeting state
const newTargeting: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
if (!newTargeting.any_of) newTargeting.any_of = []
const groups = newTargeting.any_of ?? []
for (let gi = 0; gi < groups.length; gi++) {
const allOf = groups[gi]?.all_of ?? []
for (let ci = 0; ci < allOf.length; ci++) {
const c = allOf[ci]
if (c?.type === 'subscription') {
ensureSelectionPath(gi, ci)
c.operator = 'in' as AnnouncementOperator
c.group_ids = (subscriptionSelections[gi]?.[ci] ?? []).slice()
}
}
}
// Only emit if there's an actual change (deep comparison)
if (JSON.stringify(props.modelValue) !== JSON.stringify(newTargeting)) {
emit('update:modelValue', newTargeting)
}
}, 0)
},
{ deep: true }
)
const validationError = computed(() => {
if (mode.value !== 'custom') return ''
const groups = anyOf.value
if (groups.length === 0) return t('admin.announcements.form.addOrGroup')
if (groups.length > 50) return 'any_of > 50'
for (const g of groups) {
const allOf = g?.all_of ?? []
if (allOf.length === 0) return t('admin.announcements.form.addAndCondition')
if (allOf.length > 50) return 'all_of > 50'
for (const c of allOf) {
if (c.type === 'subscription') {
if (!c.group_ids || c.group_ids.length === 0) return t('admin.announcements.form.selectPackages')
}
}
}
return ''
})
</script>

View File

@@ -0,0 +1,496 @@
<template>
<BaseDialog :show="show" :title="t('admin.groups.rateMultipliersTitle')" width="wide" @close="handleClose">
<div v-if="group" class="space-y-4">
<!-- 分组信息 -->
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-2.5 text-sm dark:bg-dark-700">
<span class="inline-flex items-center gap-1.5" :class="platformColorClass">
<PlatformIcon :platform="group.platform" size="sm" />
{{ t('admin.groups.platforms.' + group.platform) }}
</span>
<span class="text-gray-400">|</span>
<span class="font-medium text-gray-900 dark:text-white">{{ group.name }}</span>
<span class="text-gray-400">|</span>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.groups.columns.rateMultiplier') }}: {{ group.rate_multiplier }}x
</span>
</div>
<!-- 操作区 -->
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<!-- 添加用户 -->
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.addUserRate') }}
</h4>
<div class="flex items-end gap-2">
<div class="relative flex-1">
<input
v-model="searchQuery"
type="text"
autocomplete="off"
class="input w-full"
:placeholder="t('admin.groups.searchUserPlaceholder')"
@input="handleSearchUsers"
@focus="showDropdown = true"
/>
<div
v-if="showDropdown && searchResults.length > 0"
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-500 dark:bg-dark-700"
>
<button
v-for="user in searchResults"
:key="user.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
@click="selectUser(user)"
>
<span class="text-gray-400">#{{ user.id }}</span>
<span class="text-gray-900 dark:text-white">{{ user.username || user.email }}</span>
<span v-if="user.username" class="text-xs text-gray-400">{{ user.email }}</span>
</button>
</div>
</div>
<div class="w-24">
<input
v-model.number="newRate"
type="number"
step="0.001"
min="0"
autocomplete="off"
class="hide-spinner input w-full"
placeholder="1.0"
/>
</div>
<button
type="button"
class="btn btn-primary shrink-0"
:disabled="!selectedUser || !newRate"
@click="handleAddLocal"
>
{{ t('common.add') }}
</button>
</div>
<!-- 批量调整 + 全部清空 -->
<div v-if="localEntries.length > 0" class="mt-3 flex items-center gap-3 border-t border-gray-100 pt-3 dark:border-dark-600">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.batchAdjust') }}</span>
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-400">×</span>
<input
v-model.number="batchFactor"
type="number"
step="0.1"
min="0"
autocomplete="off"
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
placeholder="0.5"
/>
<button
type="button"
class="btn btn-primary btn-sm shrink-0 px-2.5 py-1 text-xs"
:disabled="!batchFactor || batchFactor <= 0"
@click="applyBatchFactor"
>
{{ t('admin.groups.applyMultiplier') }}
</button>
</div>
<div class="ml-auto">
<button
type="button"
class="rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
@click="clearAllLocal"
>
{{ t('admin.groups.clearAll') }}
</button>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-6">
<svg class="h-6 w-6 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- 已设置的用户列表 -->
<div v-else>
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.rateMultipliers') }} ({{ localEntries.length }})
</h4>
<div v-if="localEntries.length === 0" class="py-6 text-center text-sm text-gray-400 dark:text-gray-500">
{{ t('admin.groups.noRateMultipliers') }}
</div>
<div v-else>
<!-- 表格 -->
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
<div class="max-h-[420px] overflow-y-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 z-[1]">
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userEmail') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">ID</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userName') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userNotes') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userStatus') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.rateMultiplier') }}</th>
<th v-if="showFinalRate" class="px-3 py-2 text-left text-xs font-medium text-primary-600 dark:text-primary-400">{{ t('admin.groups.finalRate') }}</th>
<th class="w-10 px-2 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-600">
<tr
v-for="entry in paginatedLocalEntries"
:key="entry.user_id"
class="hover:bg-gray-50 dark:hover:bg-dark-700/50"
>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ entry.user_email }}</td>
<td class="whitespace-nowrap px-3 py-2 text-gray-400 dark:text-gray-500">{{ entry.user_id }}</td>
<td class="whitespace-nowrap px-3 py-2 text-gray-900 dark:text-white">{{ entry.user_name || '-' }}</td>
<td class="max-w-[160px] truncate px-3 py-2 text-gray-500 dark:text-gray-400" :title="entry.user_notes">{{ entry.user_notes || '-' }}</td>
<td class="whitespace-nowrap px-3 py-2">
<span
:class="[
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
entry.user_status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-400'
]"
>
{{ entry.user_status }}
</span>
</td>
<td class="whitespace-nowrap px-3 py-2">
<input
type="number"
step="0.001"
min="0"
autocomplete="off"
:value="entry.rate_multiplier"
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
@change="updateLocalRate(entry.user_id, ($event.target as HTMLInputElement).value)"
/>
</td>
<td v-if="showFinalRate" class="whitespace-nowrap px-3 py-2 font-medium text-primary-600 dark:text-primary-400">
{{ computeFinalRate(entry.rate_multiplier) }}
</td>
<td class="px-2 py-2">
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
@click="removeLocal(entry.user_id)"
>
<Icon name="trash" size="sm" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<Pagination
:total="localEntries.length"
:page="currentPage"
:page-size="pageSize"
:page-size-options="[10, 20, 50]"
@update:page="currentPage = $event"
@update:pageSize="handlePageSizeChange"
/>
</div>
</div>
<!-- 底部操作栏 -->
<div class="flex items-center gap-3 border-t border-gray-200 pt-4 dark:border-dark-600">
<!-- 左侧未保存提示 + 撤销 -->
<template v-if="isDirty">
<span class="text-xs text-amber-600 dark:text-amber-400">{{ t('admin.groups.unsavedChanges') }}</span>
<button
type="button"
class="text-xs font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
@click="handleCancel"
>
{{ t('admin.groups.revertChanges') }}
</button>
</template>
<!-- 右侧关闭 / 保存 -->
<div class="ml-auto flex items-center gap-3">
<button type="button" class="btn btn-sm px-4 py-1.5" @click="handleClose">
{{ t('common.close') }}
</button>
<button
v-if="isDirty"
type="button"
class="btn btn-primary btn-sm px-4 py-1.5"
:disabled="saving"
@click="handleSave"
>
<Icon v-if="saving" name="refresh" size="sm" class="mr-1 animate-spin" />
{{ t('common.save') }}
</button>
</div>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { GroupRateMultiplierEntry } from '@/api/admin/groups'
import type { AdminGroup, AdminUser } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Pagination from '@/components/common/Pagination.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
interface LocalEntry extends GroupRateMultiplierEntry {}
const props = defineProps<{
show: boolean
group: AdminGroup | null
}>()
const emit = defineEmits<{
close: []
success: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const saving = ref(false)
const serverEntries = ref<GroupRateMultiplierEntry[]>([])
const localEntries = ref<LocalEntry[]>([])
const searchQuery = ref('')
const searchResults = ref<AdminUser[]>([])
const showDropdown = ref(false)
const selectedUser = ref<AdminUser | null>(null)
const newRate = ref<number | null>(null)
const currentPage = ref(1)
const pageSize = ref(10)
const batchFactor = ref<number | null>(null)
let searchTimeout: ReturnType<typeof setTimeout>
const platformColorClass = computed(() => {
switch (props.group?.platform) {
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
default: return 'text-blue-700 dark:text-blue-400'
}
})
// 是否显示"最终倍率"预览列
const showFinalRate = computed(() => {
return batchFactor.value != null && batchFactor.value > 0 && batchFactor.value !== 1
})
// 计算最终倍率预览
const computeFinalRate = (rate: number) => {
if (!batchFactor.value) return rate
return parseFloat((rate * batchFactor.value).toFixed(6))
}
// 检测是否有未保存的修改
const isDirty = computed(() => {
if (localEntries.value.length !== serverEntries.value.length) return true
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier]))
return localEntries.value.some(e => {
const serverRate = serverMap.get(e.user_id)
return serverRate === undefined || serverRate !== e.rate_multiplier
})
})
const paginatedLocalEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return localEntries.value.slice(start, start + pageSize.value)
})
const cloneEntries = (entries: GroupRateMultiplierEntry[]): LocalEntry[] => {
return entries.map(e => ({ ...e }))
}
const loadEntries = async () => {
if (!props.group) return
loading.value = true
try {
serverEntries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
localEntries.value = cloneEntries(serverEntries.value)
adjustPage()
} catch (error) {
appStore.showError(t('admin.groups.failedToLoad'))
console.error('Error loading group rate multipliers:', error)
} finally {
loading.value = false
}
}
const adjustPage = () => {
const totalPages = Math.max(1, Math.ceil(localEntries.value.length / pageSize.value))
if (currentPage.value > totalPages) {
currentPage.value = totalPages
}
}
watch(() => props.show, (val) => {
if (val && props.group) {
currentPage.value = 1
batchFactor.value = null
searchQuery.value = ''
searchResults.value = []
selectedUser.value = null
newRate.value = null
loadEntries()
}
})
const handlePageSizeChange = (newSize: number) => {
pageSize.value = newSize
currentPage.value = 1
}
const handleSearchUsers = () => {
clearTimeout(searchTimeout)
selectedUser.value = null
if (!searchQuery.value.trim()) {
searchResults.value = []
showDropdown.value = false
return
}
searchTimeout = setTimeout(async () => {
try {
const res = await adminAPI.users.list(1, 10, { search: searchQuery.value.trim() })
searchResults.value = res.items
showDropdown.value = true
} catch {
searchResults.value = []
}
}, 300)
}
const selectUser = (user: AdminUser) => {
selectedUser.value = user
searchQuery.value = user.email
showDropdown.value = false
searchResults.value = []
}
// 本地添加(或覆盖已有用户)
const handleAddLocal = () => {
if (!selectedUser.value || !newRate.value) return
const user = selectedUser.value
const idx = localEntries.value.findIndex(e => e.user_id === user.id)
const entry: LocalEntry = {
user_id: user.id,
user_name: user.username || '',
user_email: user.email,
user_notes: user.notes || '',
user_status: user.status || 'active',
rate_multiplier: newRate.value
}
if (idx >= 0) {
localEntries.value[idx] = entry
} else {
localEntries.value.push(entry)
}
searchQuery.value = ''
selectedUser.value = null
newRate.value = null
adjustPage()
}
// 本地修改倍率
const updateLocalRate = (userId: number, value: string) => {
const num = parseFloat(value)
if (isNaN(num)) return
const entry = localEntries.value.find(e => e.user_id === userId)
if (entry) {
entry.rate_multiplier = num
}
}
// 本地删除
const removeLocal = (userId: number) => {
localEntries.value = localEntries.value.filter(e => e.user_id !== userId)
adjustPage()
}
// 批量乘数应用到本地
const applyBatchFactor = () => {
if (!batchFactor.value || batchFactor.value <= 0) return
for (const entry of localEntries.value) {
entry.rate_multiplier = parseFloat((entry.rate_multiplier * batchFactor.value).toFixed(6))
}
batchFactor.value = null
}
// 本地清空
const clearAllLocal = () => {
localEntries.value = []
}
// 取消:恢复到服务器数据
const handleCancel = () => {
localEntries.value = cloneEntries(serverEntries.value)
batchFactor.value = null
adjustPage()
}
// 保存:一次性提交所有数据
const handleSave = async () => {
if (!props.group) return
saving.value = true
try {
const entries = localEntries.value.map(e => ({
user_id: e.user_id,
rate_multiplier: e.rate_multiplier
}))
await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, entries)
appStore.showSuccess(t('admin.groups.rateSaved'))
emit('success')
emit('close')
} catch (error) {
appStore.showError(t('admin.groups.failedToSave'))
console.error('Error saving rate multipliers:', error)
} finally {
saving.value = false
}
}
// 关闭时如果有未保存修改,先恢复
const handleClose = () => {
if (isDirty.value) {
localEntries.value = cloneEntries(serverEntries.value)
}
emit('close')
}
// 点击外部关闭下拉
const handleClickOutside = () => {
showDropdown.value = false
}
if (typeof document !== 'undefined') {
document.addEventListener('click', handleClickOutside)
}
</script>
<style scoped>
.hide-spinner::-webkit-outer-spin-button,
.hide-spinner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.hide-spinner {
-moz-appearance: textfield;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.proxies.dataImportTitle')"
width="normal"
close-on-click-outside
@close="handleClose"
>
<form id="import-proxy-data-form" class="space-y-4" @submit.prevent="handleImport">
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.proxies.dataImportHint') }}
</div>
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('admin.proxies.dataImportWarning') }}
</div>
<div>
<label class="input-label">{{ t('admin.proxies.dataImportFile') }}</label>
<div
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
>
<div class="min-w-0">
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
{{ fileName || t('admin.proxies.dataImportSelectFile') }}
</div>
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
</div>
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
{{ t('common.chooseFile') }}
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
accept="application/json,.json"
@change="handleFileChange"
/>
</div>
<div
v-if="result"
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.proxies.dataImportResult') }}
</div>
<div class="text-sm text-gray-700 dark:text-dark-300">
{{ t('admin.proxies.dataImportResultSummary', result) }}
</div>
<div v-if="errorItems.length" class="mt-2">
<div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.proxies.dataImportErrors') }}
</div>
<div
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} {{ item.message }}
</div>
</div>
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="import-proxy-data-form"
:disabled="importing"
>
{{ importing ? t('admin.proxies.dataImporting') : t('admin.proxies.dataImportButton') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
import type { AdminDataImportResult } from '@/types'
interface Props {
show: boolean
}
interface Emits {
(e: 'close'): void
(e: 'imported'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
const importing = ref(false)
const file = ref<File | null>(null)
const result = ref<AdminDataImportResult | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const fileName = computed(() => file.value?.name || '')
const errorItems = computed(() => result.value?.errors || [])
watch(
() => props.show,
(open) => {
if (open) {
file.value = null
result.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
)
const openFilePicker = () => {
fileInput.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
file.value = target.files?.[0] || null
}
const handleClose = () => {
if (importing.value) return
emit('close')
}
const readFileAsText = async (sourceFile: File): Promise<string> => {
if (typeof sourceFile.text === 'function') {
return sourceFile.text()
}
if (typeof sourceFile.arrayBuffer === 'function') {
const buffer = await sourceFile.arrayBuffer()
return new TextDecoder().decode(buffer)
}
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.readAsText(sourceFile)
})
}
const handleImport = async () => {
if (!file.value) {
appStore.showError(t('admin.proxies.dataImportSelectFile'))
return
}
importing.value = true
try {
const text = await readFileAsText(file.value)
const dataPayload = JSON.parse(text)
const res = await adminAPI.proxies.importData({ data: dataPayload })
result.value = res
const msgParams: Record<string, unknown> = {
proxy_created: res.proxy_created,
proxy_reused: res.proxy_reused,
proxy_failed: res.proxy_failed
}
if (res.proxy_failed > 0) {
appStore.showError(t('admin.proxies.dataImportCompletedWithErrors', msgParams))
} else {
appStore.showSuccess(t('admin.proxies.dataImportSuccess', msgParams))
emit('imported')
}
} catch (error: any) {
if (error instanceof SyntaxError) {
appStore.showError(t('admin.proxies.dataImportParseFailed'))
} else {
appStore.showError(error?.message || t('admin.proxies.dataImportFailed'))
}
} finally {
importing.value = false
}
}
</script>

View File

@@ -0,0 +1,387 @@
<template>
<BaseDialog :show="show" :title="t('admin.usage.cleanup.title')" width="wide" @close="handleClose">
<div class="space-y-4">
<UsageFilters
v-model="localFilters"
v-model:startDate="localStartDate"
v-model:endDate="localEndDate"
:exporting="false"
:show-actions="false"
@change="noop"
/>
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{{ t('admin.usage.cleanup.warning') }}
</div>
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
{{ t('admin.usage.cleanup.recentTasks') }}
</h4>
<button type="button" class="btn btn-ghost btn-sm" @click="loadTasks">
{{ t('common.refresh') }}
</button>
</div>
<div class="mt-3 space-y-2">
<div v-if="tasksLoading" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.usage.cleanup.loadingTasks') }}
</div>
<div v-else-if="tasks.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.usage.cleanup.noTasks') }}
</div>
<div v-else class="space-y-2">
<div
v-for="task in tasks"
:key="task.id"
class="flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span :class="statusClass(task.status)" class="rounded-full px-2 py-0.5 text-xs font-semibold">
{{ statusLabel(task.status) }}
</span>
<span class="text-xs text-gray-400">#{{ task.id }}</span>
<button
v-if="canCancel(task)"
type="button"
class="btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
@click="openCancelConfirm(task)"
>
{{ t('admin.usage.cleanup.cancel') }}
</button>
</div>
<div class="text-xs text-gray-400">
{{ formatDateTime(task.created_at) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>{{ t('admin.usage.cleanup.range') }}: {{ formatRange(task) }}</span>
<span>{{ t('admin.usage.cleanup.deletedRows') }}: {{ task.deleted_rows.toLocaleString() }}</span>
</div>
<div v-if="task.error_message" class="text-xs text-rose-500">
{{ task.error_message }}
</div>
</div>
</div>
</div>
<Pagination
v-if="tasksTotal > tasksPageSize"
class="mt-4"
:total="tasksTotal"
:page="tasksPage"
:page-size="tasksPageSize"
:page-size-options="[5]"
:show-page-size-selector="false"
:show-jump="true"
@update:page="handleTaskPageChange"
@update:pageSize="handleTaskPageSizeChange"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button type="button" class="btn btn-danger" :disabled="submitting" @click="openConfirm">
{{ submitting ? t('admin.usage.cleanup.submitting') : t('admin.usage.cleanup.submit') }}
</button>
</div>
</template>
</BaseDialog>
<ConfirmDialog
:show="confirmVisible"
:title="t('admin.usage.cleanup.confirmTitle')"
:message="t('admin.usage.cleanup.confirmMessage')"
:confirm-text="t('admin.usage.cleanup.confirmSubmit')"
danger
@confirm="submitCleanup"
@cancel="confirmVisible = false"
/>
<ConfirmDialog
:show="cancelConfirmVisible"
:title="t('admin.usage.cleanup.cancelConfirmTitle')"
:message="t('admin.usage.cleanup.cancelConfirmMessage')"
:confirm-text="t('admin.usage.cleanup.cancelConfirm')"
danger
@confirm="cancelTask"
@cancel="cancelConfirmVisible = false"
/>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Pagination from '@/components/common/Pagination.vue'
import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import { adminUsageAPI } from '@/api/admin/usage'
import type { AdminUsageQueryParams, UsageCleanupTask, CreateUsageCleanupTaskRequest } from '@/api/admin/usage'
import { requestTypeToLegacyStream } from '@/utils/usageRequestType'
interface Props {
show: boolean
filters: AdminUsageQueryParams
startDate: string
endDate: string
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
const { t } = useI18n()
const appStore = useAppStore()
const localFilters = ref<AdminUsageQueryParams>({})
const localStartDate = ref('')
const localEndDate = ref('')
const tasks = ref<UsageCleanupTask[]>([])
const tasksLoading = ref(false)
const tasksPage = ref(1)
const tasksPageSize = ref(5)
const tasksTotal = ref(0)
const submitting = ref(false)
const confirmVisible = ref(false)
const cancelConfirmVisible = ref(false)
const canceling = ref(false)
const cancelTarget = ref<UsageCleanupTask | null>(null)
let pollTimer: number | null = null
const noop = () => {}
const resetFilters = () => {
localFilters.value = { ...props.filters }
localStartDate.value = props.startDate
localEndDate.value = props.endDate
localFilters.value.start_date = localStartDate.value
localFilters.value.end_date = localEndDate.value
tasksPage.value = 1
tasksTotal.value = 0
}
const startPolling = () => {
stopPolling()
pollTimer = window.setInterval(() => {
loadTasks()
}, 10000)
}
const stopPolling = () => {
if (pollTimer !== null) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
const handleClose = () => {
stopPolling()
confirmVisible.value = false
cancelConfirmVisible.value = false
canceling.value = false
cancelTarget.value = null
submitting.value = false
emit('close')
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
pending: t('admin.usage.cleanup.status.pending'),
running: t('admin.usage.cleanup.status.running'),
succeeded: t('admin.usage.cleanup.status.succeeded'),
failed: t('admin.usage.cleanup.status.failed'),
canceled: t('admin.usage.cleanup.status.canceled')
}
return map[status] || status
}
const statusClass = (status: string) => {
const map: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
running: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200',
succeeded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
failed: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
canceled: 'bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300'
}
return map[status] || 'bg-gray-100 text-gray-600'
}
const formatDateTime = (value?: string | null) => {
if (!value) return '--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
const formatRange = (task: UsageCleanupTask) => {
const start = formatDateTime(task.filters.start_time)
const end = formatDateTime(task.filters.end_time)
return `${start} ~ ${end}`
}
const getUserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
return 'UTC'
}
}
const loadTasks = async () => {
if (!props.show) return
tasksLoading.value = true
try {
const res = await adminUsageAPI.listCleanupTasks({
page: tasksPage.value,
page_size: tasksPageSize.value
})
tasks.value = res.items || []
tasksTotal.value = res.total || 0
if (res.page) {
tasksPage.value = res.page
}
if (res.page_size) {
tasksPageSize.value = res.page_size
}
} catch (error) {
console.error('Failed to load cleanup tasks:', error)
appStore.showError(t('admin.usage.cleanup.loadFailed'))
} finally {
tasksLoading.value = false
}
}
const handleTaskPageChange = (page: number) => {
tasksPage.value = page
loadTasks()
}
const handleTaskPageSizeChange = (size: number) => {
if (!Number.isFinite(size) || size <= 0) return
tasksPageSize.value = size
tasksPage.value = 1
loadTasks()
}
const openConfirm = () => {
confirmVisible.value = true
}
const canCancel = (task: UsageCleanupTask) => {
return task.status === 'pending' || task.status === 'running'
}
const openCancelConfirm = (task: UsageCleanupTask) => {
cancelTarget.value = task
cancelConfirmVisible.value = true
}
const buildPayload = (): CreateUsageCleanupTaskRequest | null => {
if (!localStartDate.value || !localEndDate.value) {
appStore.showError(t('admin.usage.cleanup.missingRange'))
return null
}
const payload: CreateUsageCleanupTaskRequest = {
start_date: localStartDate.value,
end_date: localEndDate.value,
timezone: getUserTimezone()
}
if (localFilters.value.user_id && localFilters.value.user_id > 0) {
payload.user_id = localFilters.value.user_id
}
if (localFilters.value.api_key_id && localFilters.value.api_key_id > 0) {
payload.api_key_id = localFilters.value.api_key_id
}
if (localFilters.value.account_id && localFilters.value.account_id > 0) {
payload.account_id = localFilters.value.account_id
}
if (localFilters.value.group_id && localFilters.value.group_id > 0) {
payload.group_id = localFilters.value.group_id
}
if (localFilters.value.model) {
payload.model = localFilters.value.model
}
if (localFilters.value.request_type) {
payload.request_type = localFilters.value.request_type
const legacyStream = requestTypeToLegacyStream(localFilters.value.request_type)
if (legacyStream !== null && legacyStream !== undefined) {
payload.stream = legacyStream
}
} else if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
payload.stream = localFilters.value.stream
}
if (localFilters.value.billing_type !== null && localFilters.value.billing_type !== undefined) {
payload.billing_type = localFilters.value.billing_type
}
return payload
}
const submitCleanup = async () => {
const payload = buildPayload()
if (!payload) {
confirmVisible.value = false
return
}
submitting.value = true
confirmVisible.value = false
try {
await adminUsageAPI.createCleanupTask(payload)
appStore.showSuccess(t('admin.usage.cleanup.submitSuccess'))
loadTasks()
} catch (error) {
console.error('Failed to create cleanup task:', error)
appStore.showError(t('admin.usage.cleanup.submitFailed'))
} finally {
submitting.value = false
}
}
const cancelTask = async () => {
const task = cancelTarget.value
if (!task) {
cancelConfirmVisible.value = false
return
}
canceling.value = true
cancelConfirmVisible.value = false
try {
await adminUsageAPI.cancelCleanupTask(task.id)
appStore.showSuccess(t('admin.usage.cleanup.cancelSuccess'))
loadTasks()
} catch (error) {
console.error('Failed to cancel cleanup task:', error)
appStore.showError(t('admin.usage.cleanup.cancelFailed'))
} finally {
canceling.value = false
cancelTarget.value = null
}
}
watch(
() => props.show,
(show) => {
if (show) {
resetFilters()
loadTasks()
startPolling()
} else {
stopPolling()
}
}
)
onUnmounted(() => {
stopPolling()
})
</script>

View File

@@ -0,0 +1,16 @@
<template>
<ExportProgressDialog
:show="show"
:progress="progress"
:current="current"
:total="total"
:estimated-time="estimatedTime"
@cancel="$emit('cancel')"
/>
</template>
<script setup lang="ts">
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
defineProps<{ show: boolean, progress: number, current: number, total: number, estimatedTime: string }>()
defineEmits(['cancel'])
</script>

View File

@@ -0,0 +1,458 @@
<template>
<div class="card p-6">
<!-- Toolbar: left filters (multi-line) + right actions -->
<div class="flex flex-wrap items-end justify-between gap-4">
<!-- Left: filters (allowed to wrap to multiple rows) -->
<div class="flex flex-1 flex-wrap items-end gap-4">
<!-- User Search -->
<div ref="userSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
<input
v-model="userKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchUserPlaceholder')"
@input="debounceUserSearch"
@focus="showUserDropdown = true"
/>
<button
v-if="filters.user_id"
type="button"
@click="clearUser"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear user filter"
>
</button>
<div
v-if="showUserDropdown && (userResults.length > 0 || userKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="u in userResults"
:key="u.id"
type="button"
@click="selectUser(u)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>{{ u.email }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span>
</button>
</div>
</div>
<!-- API Key Search -->
<div ref="apiKeySearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
<input
v-model="apiKeyKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
@input="debounceApiKeySearch"
@focus="onApiKeyFocus"
/>
<button
v-if="filters.api_key_id"
type="button"
@click="onClearApiKey"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear API key filter"
>
</button>
<div
v-if="showApiKeyDropdown && apiKeyResults.length > 0"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="k in apiKeyResults"
:key="k.id"
type="button"
@click="selectApiKey(k)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="truncate">{{ k.name || `#${k.id}` }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ k.id }}</span>
</button>
</div>
</div>
<!-- Model Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('usage.model') }}</label>
<Select v-model="filters.model" :options="modelOptions" searchable @change="emitChange" />
</div>
<!-- Account Filter -->
<div ref="accountSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.account') }}</label>
<input
v-model="accountKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchAccountPlaceholder')"
@input="debounceAccountSearch"
@focus="showAccountDropdown = true"
/>
<button
v-if="filters.account_id"
type="button"
@click="clearAccount"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear account filter"
>
</button>
<div
v-if="showAccountDropdown && (accountResults.length > 0 || accountKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="a in accountResults"
:key="a.id"
type="button"
@click="selectAccount(a)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="truncate">{{ a.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ a.id }}</span>
</button>
</div>
</div>
<!-- Request Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select v-model="filters.request_type" :options="requestTypeOptions" @change="emitChange" />
</div>
<!-- Billing Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
</div>
<!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
<Select v-model="filters.group_id" :options="groupOptions" searchable @change="emitChange" />
</div>
<!-- Date Range Filter -->
<div class="w-full sm:w-auto [&_.date-picker-trigger]:w-full">
<label class="input-label">{{ t('usage.timeRange') }}</label>
<DateRangePicker
:start-date="startDate"
:end-date="endDate"
@update:startDate="updateStartDate"
@update:endDate="updateEndDate"
@change="emitChange"
/>
</div>
</div>
<!-- Right: actions -->
<div v-if="showActions" class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
<button type="button" @click="$emit('refresh')" class="btn btn-secondary">
{{ t('common.refresh') }}
</button>
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
{{ t('common.reset') }}
</button>
<slot name="after-reset" />
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
{{ t('admin.usage.cleanup.button') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
type ModelValue = Record<string, any>
interface Props {
modelValue: ModelValue
exporting: boolean
startDate: string
endDate: string
showActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showActions: true
})
const emit = defineEmits([
'update:modelValue',
'update:startDate',
'update:endDate',
'change',
'refresh',
'reset',
'export',
'cleanup'
])
const { t } = useI18n()
const filters = toRef(props, 'modelValue')
const userSearchRef = ref<HTMLElement | null>(null)
const apiKeySearchRef = ref<HTMLElement | null>(null)
const accountSearchRef = ref<HTMLElement | null>(null)
const userKeyword = ref('')
const userResults = ref<SimpleUser[]>([])
const showUserDropdown = ref(false)
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const apiKeyKeyword = ref('')
const apiKeyResults = ref<SimpleApiKey[]>([])
const showApiKeyDropdown = ref(false)
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
interface SimpleAccount {
id: number
name: string
}
const accountKeyword = ref('')
const accountResults = ref<SimpleAccount[]>([])
const showAccountDropdown = ref(false)
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
const requestTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allTypes') },
{ value: 'ws_v2', label: t('usage.ws') },
{ value: 'stream', label: t('usage.stream') },
{ value: 'sync', label: t('usage.sync') }
])
const billingTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingTypes') },
{ value: 0, label: t('admin.usage.billingTypeBalance') },
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
])
const emitChange = () => emit('change')
const updateStartDate = (value: string) => {
emit('update:startDate', value)
filters.value.start_date = value
}
const updateEndDate = (value: string) => {
emit('update:endDate', value)
filters.value.end_date = value
}
const debounceUserSearch = () => {
if (userSearchTimeout) clearTimeout(userSearchTimeout)
userSearchTimeout = setTimeout(async () => {
if (!userKeyword.value) {
userResults.value = []
return
}
try {
userResults.value = await adminAPI.usage.searchUsers(userKeyword.value)
} catch {
userResults.value = []
}
}, 300)
}
const debounceApiKeySearch = () => {
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
apiKeySearchTimeout = setTimeout(async () => {
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
filters.value.user_id,
apiKeyKeyword.value || ''
)
} catch {
apiKeyResults.value = []
}
}, 300)
}
const selectUser = async (u: SimpleUser) => {
userKeyword.value = u.email
showUserDropdown.value = false
filters.value.user_id = u.id
clearApiKey()
// Auto-load API keys for this user
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(u.id, '')
} catch {
apiKeyResults.value = []
}
emitChange()
}
const clearUser = () => {
userKeyword.value = ''
userResults.value = []
showUserDropdown.value = false
filters.value.user_id = undefined
clearApiKey()
emitChange()
}
const selectApiKey = (k: SimpleApiKey) => {
apiKeyKeyword.value = k.name || String(k.id)
showApiKeyDropdown.value = false
filters.value.api_key_id = k.id
emitChange()
}
const clearApiKey = () => {
apiKeyKeyword.value = ''
apiKeyResults.value = []
showApiKeyDropdown.value = false
filters.value.api_key_id = undefined
}
const onClearApiKey = () => {
clearApiKey()
emitChange()
}
const debounceAccountSearch = () => {
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
accountSearchTimeout = setTimeout(async () => {
if (!accountKeyword.value) {
accountResults.value = []
return
}
try {
const res = await adminAPI.accounts.list(1, 20, { search: accountKeyword.value })
accountResults.value = res.items.map((a) => ({ id: a.id, name: a.name }))
} catch {
accountResults.value = []
}
}, 300)
}
const selectAccount = (a: SimpleAccount) => {
accountKeyword.value = a.name
showAccountDropdown.value = false
filters.value.account_id = a.id
emitChange()
}
const clearAccount = () => {
accountKeyword.value = ''
accountResults.value = []
showAccountDropdown.value = false
filters.value.account_id = undefined
emitChange()
}
const onApiKeyFocus = () => {
showApiKeyDropdown.value = true
// Trigger search if no results yet
if (apiKeyResults.value.length === 0) {
debounceApiKeySearch()
}
}
const onDocumentClick = (e: MouseEvent) => {
const target = e.target as Node | null
if (!target) return
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false
if (!clickedInsideUser) showUserDropdown.value = false
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
if (!clickedInsideAccount) showAccountDropdown.value = false
}
watch(
() => props.startDate,
(value) => {
filters.value.start_date = value
},
{ immediate: true }
)
watch(
() => props.endDate,
(value) => {
filters.value.end_date = value
},
{ immediate: true }
)
watch(
() => filters.value.user_id,
(userId) => {
if (!userId) {
userKeyword.value = ''
userResults.value = []
}
}
)
watch(
() => filters.value.api_key_id,
(apiKeyId) => {
if (!apiKeyId) {
apiKeyKeyword.value = ''
apiKeyResults.value = []
}
}
)
watch(
() => filters.value.account_id,
(accountId) => {
if (!accountId) {
accountKeyword.value = ''
accountResults.value = []
}
}
)
onMounted(async () => {
document.addEventListener('click', onDocumentClick)
try {
const [gs, ms] = await Promise.all([
adminAPI.groups.list(1, 1000),
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
])
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
const uniqueModels = new Set<string>()
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
modelOptions.value.push(
...Array.from(uniqueModels)
.sort()
.map((m) => ({ value: m, label: m }))
)
} catch {
// Ignore filter option loading errors (page still usable)
}
})
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick)
})
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
<Icon name="document" size="md" />
</div>
<div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
</div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
<div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
<p class="text-xs text-gray-500">
{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} /
{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}
</p>
</div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
<Icon name="dollar" size="md" />
</div>
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">
${{ ((stats?.total_account_cost ?? stats?.total_actual_cost) || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-400" v-if="stats?.total_account_cost != null">
{{ t('usage.userBilled') }}:
<span class="text-gray-300">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</span>
· {{ t('usage.standardCost') }}:
<span class="text-gray-300">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
<p class="text-xs text-gray-400" v-else>
{{ t('usage.standardCost') }}:
<span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
</div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
<Icon name="clock" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.avgDuration') }}</p><p class="text-xl font-bold">{{ formatDuration(stats?.average_duration_ms || 0) }}</p></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
import Icon from '@/components/icons/Icon.vue'
defineProps<{ stats: AdminUsageStatsResponse | null }>()
const { t } = useI18n()
const formatDuration = (ms: number) =>
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
const formatTokens = (value: number) => {
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B'
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M'
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K'
return value.toLocaleString()
}
</script>

Some files were not shown because too many files have changed in this diff Show More