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,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>

View File

@@ -0,0 +1,390 @@
<template>
<div class="card overflow-hidden">
<div class="overflow-auto">
<DataTable :columns="columns" :data="data" :loading="loading">
<template #cell-user="{ row }">
<div class="text-sm">
<button
v-if="row.user?.email"
class="font-medium text-primary-600 underline decoration-dashed underline-offset-2 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
@click="$emit('userClick', row.user_id, row.user?.email)"
:title="t('admin.usage.clickToViewBalance')"
>
{{ row.user.email }}
</button>
<span v-else class="font-medium text-gray-900 dark:text-white">-</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
</div>
</template>
<template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
</template>
<template #cell-account="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
</template>
<template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-reasoning_effort="{ row }">
<span class="text-sm text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.reasoning_effort) }}
</span>
</template>
<template #cell-endpoint="{ row }">
<div class="max-w-[320px] space-y-1 text-xs">
<div class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.inbound') }}:</span>
<span class="ml-1">{{ row.inbound_endpoint?.trim() || '-' }}</span>
</div>
<div class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.upstream') }}:</span>
<span class="ml-1">{{ row.upstream_endpoint?.trim() || '-' }}</span>
</div>
</div>
</template>
<template #cell-group="{ row }">
<span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
{{ row.group.name }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-stream="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getRequestTypeBadgeClass(row)">
{{ getRequestTypeLabel(row) }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求 -->
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
</div>
<!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5">
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
</div>
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
</div>
</div>
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
</div>
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
class="group relative"
@mouseenter="showTokenTooltip($event, row)"
@mouseleave="hideTokenTooltip"
>
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div>
</div>
</template>
<template #cell-cost="{ row }">
<div class="text-sm">
<div class="flex items-center gap-1.5">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<!-- Cost Detail Tooltip -->
<div
class="group relative"
@mouseenter="showTooltip($event, row)"
@mouseleave="hideTooltip"
>
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
</div>
</div>
</template>
<template #cell-first_token="{ row }">
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-duration="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-user_agent="{ row }">
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-ip_address="{ row }">
<span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable>
</div>
</div>
<!-- Token Tooltip Portal -->
<Teleport to="body">
<div
v-if="tokenTooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5">
<div>
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<!-- 5m/1h 明细时展开显示 -->
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation5mTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation1hTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
</div>
</template>
<!-- 无明细时只显示聚合值 -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('usage.cacheTtlOverriddenLabel') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
</span>
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
</div>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
</div>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
</div>
</Teleport>
<!-- Cost Tooltip Portal -->
<Teleport to="body">
<div
v-if="tooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5">
<!-- Cost Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Rate and Summary -->
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.serviceTier') }}</span>
<span class="font-semibold text-cyan-300">{{ getUsageServiceTierLabel(tooltipData?.service_tier, t) }}</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
</span>
</div>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types'
defineProps(['data', 'loading', 'columns'])
defineEmits(['userClick'])
const { t } = useI18n()
// Tooltip state - cost
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<AdminUsageLog | null>(null)
// Tooltip state - token
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<AdminUsageLog | null>(null)
const getRequestTypeLabel = (row: AdminUsageLog): string => {
const requestType = resolveUsageRequestType(row)
if (requestType === 'ws_v2') return t('usage.ws')
if (requestType === 'stream') return t('usage.stream')
if (requestType === 'sync') return t('usage.sync')
return t('usage.unknown')
}
const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
const requestType = resolveUsageRequestType(row)
if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}
const formatCacheTokens = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return tokens.toString()
}
const formatUserAgent = (ua: string): string => {
return ua
}
const formatDuration = (ms: number | null | undefined): string => {
if (ms == null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
// Cost tooltip functions
const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tooltipData.value = row
tooltipPosition.value.x = rect.right + 8
tooltipPosition.value.y = rect.top + rect.height / 2
tooltipVisible.value = true
}
const hideTooltip = () => {
tooltipVisible.value = false
tooltipData.value = null
}
// Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
tokenTooltipPosition.value.x = rect.right + 8
tokenTooltipPosition.value.y = rect.top + rect.height / 2
tokenTooltipVisible.value = true
}
const hideTokenTooltip = () => {
tokenTooltipVisible.value = false
tokenTooltipData.value = null
}
</script>

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import UsageTable from '../UsageTable.vue'
const messages: Record<string, string> = {
'usage.costDetails': 'Cost Breakdown',
'admin.usage.inputCost': 'Input Cost',
'admin.usage.outputCost': 'Output Cost',
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
'admin.usage.cacheReadCost': 'Cache Read Cost',
'usage.inputTokenPrice': 'Input price',
'usage.outputTokenPrice': 'Output price',
'usage.perMillionTokens': '/ 1M tokens',
'usage.serviceTier': 'Service tier',
'usage.serviceTierPriority': 'Fast',
'usage.serviceTierFlex': 'Flex',
'usage.serviceTierStandard': 'Standard',
'usage.rate': 'Rate',
'usage.accountMultiplier': 'Account rate',
'usage.original': 'Original',
'usage.userBilled': 'User billed',
'usage.accountBilled': 'Account billed',
}
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}
})
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
}
describe('admin UsageTable tooltip', () => {
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 20,
left: 20,
right: 120,
bottom: 40,
width: 100,
height: 20,
toJSON: () => ({}),
} as DOMRect)
})
it('shows service tier and billing breakdown in cost tooltip', async () => {
const row = {
request_id: 'req-admin-1',
actual_cost: 0.092883,
total_cost: 0.092883,
account_rate_multiplier: 1,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
}
const wrapper = mount(UsageTable, {
props: {
data: [row],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
await wrapper.find('.group.relative').trigger('mouseenter')
await nextTick()
const text = wrapper.text()
expect(text).toContain('Service tier')
expect(text).toContain('Fast')
expect(text).toContain('Rate')
expect(text).toContain('1.00x')
expect(text).toContain('Account rate')
expect(text).toContain('User billed')
expect(text).toContain('Account billed')
expect(text).toContain('$0.092883')
expect(text).toContain('$5.0000 / 1M tokens')
expect(text).toContain('$30.0000 / 1M tokens')
expect(text).toContain('$0.069568')
})
})

View File

@@ -0,0 +1,328 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.groupConfig')" width="wide" @close="$emit('close')">
<div v-if="user" class="space-y-6">
<!-- 用户信息头部 -->
<div class="flex items-center gap-4 rounded-2xl bg-gradient-to-r from-primary-50 to-primary-100 p-5 dark:from-primary-900/30 dark:to-primary-800/20">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-sm dark:bg-dark-700">
<span class="text-2xl font-semibold text-primary-600 dark:text-primary-400">{{ user.email.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.email }}</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ t('admin.users.groupConfigHint', { email: user.email }) }}</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-12">
<svg class="h-10 w-10 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 class="space-y-6">
<!-- 专属分组区域 -->
<div v-if="exclusiveGroups.length > 0">
<div class="mb-3 flex items-center gap-2">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500"></div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.exclusiveGroups') }}</h4>
<span class="text-xs text-gray-400">({{ exclusiveGroupConfigs.filter(c => c.isSelected).length }}/{{ exclusiveGroupConfigs.length }})</span>
</div>
<div class="grid gap-3">
<div
v-for="config in exclusiveGroupConfigs"
:key="config.groupId"
class="group relative overflow-hidden rounded-xl border-2 p-4 transition-all duration-200"
:class="config.isSelected
? 'border-primary-400 bg-primary-50/50 shadow-sm dark:border-primary-500 dark:bg-primary-900/20'
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:hover:border-dark-500'"
>
<div class="flex items-center gap-4">
<!-- 复选框 -->
<div class="flex-shrink-0">
<label class="relative flex h-6 w-6 cursor-pointer items-center justify-center">
<input
type="checkbox"
:checked="config.isSelected"
@change="toggleExclusiveGroup(config.groupId)"
class="peer sr-only"
/>
<div class="h-5 w-5 rounded-md border-2 border-gray-300 transition-all peer-checked:border-primary-500 peer-checked:bg-primary-500 dark:border-dark-500 peer-checked:dark:border-primary-500">
<svg v-if="config.isSelected" class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</label>
</div>
<!-- 分组信息 -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
{{ t('admin.groups.exclusive') }}
</span>
</div>
<div class="mt-1.5 flex items-center gap-3 text-sm">
<span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<PlatformIcon :platform="config.platform" size="xs" />
<span>{{ config.platform }}</span>
</span>
<span class="text-gray-300 dark:text-dark-500"></span>
<span class="text-gray-500 dark:text-gray-400">
{{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span>
</span>
</div>
</div>
<!-- 专属倍率输入 -->
<div class="flex flex-shrink-0 items-center gap-3">
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label>
<input
type="number"
step="0.001"
min="0"
:value="config.customRate ?? ''"
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
:placeholder="String(config.defaultRate)"
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 公开分组区域 -->
<div v-if="publicGroups.length > 0">
<div class="mb-3 flex items-center gap-2">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.publicGroups') }}</h4>
<span class="text-xs text-gray-400">({{ publicGroupConfigs.length }})</span>
</div>
<div class="grid gap-3">
<div
v-for="config in publicGroupConfigs"
:key="config.groupId"
class="relative overflow-hidden rounded-xl border-2 border-green-200 bg-green-50/50 p-4 dark:border-green-800/50 dark:bg-green-900/10"
>
<div class="flex items-center gap-4">
<!-- 复选框禁用状态 -->
<div class="flex-shrink-0">
<div class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600">
<svg class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<!-- 分组信息 -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span>
</div>
<div class="mt-1.5 flex items-center gap-3 text-sm">
<span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<PlatformIcon :platform="config.platform" size="xs" />
<span>{{ config.platform }}</span>
</span>
<span class="text-gray-300 dark:text-dark-500"></span>
<span class="text-gray-500 dark:text-gray-400">
{{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span>
</span>
</div>
</div>
<!-- 专属倍率输入 -->
<div class="flex flex-shrink-0 items-center gap-3">
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label>
<input
type="number"
step="0.001"
min="0"
:value="config.customRate ?? ''"
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
:placeholder="String(config.defaultRate)"
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 无分组提示 -->
<div v-if="groups.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p class="text-gray-500 dark:text-gray-400">{{ t('common.noGroupsAvailable') }}</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="btn btn-secondary px-5">{{ t('common.cancel') }}</button>
<button @click="handleSave" :disabled="submitting" class="btn btn-primary px-6">
<svg v-if="submitting" 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>
{{ submitting ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { AdminUser, Group, GroupPlatform } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
interface GroupRateConfig {
groupId: number
groupName: string
platform: GroupPlatform
isExclusive: boolean
defaultRate: number
customRate: number | null
isSelected: boolean
}
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n()
const appStore = useAppStore()
const groups = ref<Group[]>([])
const groupConfigs = ref<GroupRateConfig[]>([])
const originalGroupRates = ref<Record<number, number>>({}) // 记录原始专属倍率,用于检测删除
const loading = ref(false)
const submitting = ref(false)
// 分离专属分组和公开分组
const exclusiveGroups = computed(() => groups.value.filter((g) => g.is_exclusive))
const publicGroups = computed(() => groups.value.filter((g) => !g.is_exclusive))
const exclusiveGroupConfigs = computed(() => groupConfigs.value.filter((c) => c.isExclusive))
const publicGroupConfigs = computed(() => groupConfigs.value.filter((c) => !c.isExclusive))
watch(
() => props.show,
(v) => {
if (v && props.user) {
load()
}
}
)
const load = async () => {
loading.value = true
try {
const res = await adminAPI.groups.list(1, 1000)
// 只显示标准类型且活跃的分组
groups.value = res.items.filter((g) => g.subscription_type === 'standard' && g.status === 'active')
// 初始化配置
const userAllowedGroups = props.user?.allowed_groups || []
const userGroupRates = props.user?.group_rates || {}
// 保存原始专属倍率,用于检测删除操作
originalGroupRates.value = { ...userGroupRates }
groupConfigs.value = groups.value.map((g) => ({
groupId: g.id,
groupName: g.name,
platform: g.platform,
isExclusive: g.is_exclusive,
defaultRate: g.rate_multiplier,
customRate: userGroupRates[g.id] ?? null,
// 专属分组:检查是否在 allowed_groups 中
// 公开分组:始终选中
isSelected: g.is_exclusive ? userAllowedGroups.includes(g.id) : true,
}))
} catch (error) {
console.error('Failed to load groups:', error)
} finally {
loading.value = false
}
}
const toggleExclusiveGroup = (groupId: number) => {
const config = groupConfigs.value.find((c) => c.groupId === groupId)
if (config && config.isExclusive) {
config.isSelected = !config.isSelected
}
}
const updateCustomRate = (groupId: number, value: string) => {
const config = groupConfigs.value.find((c) => c.groupId === groupId)
if (config) {
if (value === '' || value === null || value === undefined) {
config.customRate = null
} else {
const numValue = parseFloat(value)
config.customRate = isNaN(numValue) ? null : numValue
}
}
}
const handleSave = async () => {
if (!props.user) return
submitting.value = true
try {
// 构建 allowed_groups仅包含专属分组中被勾选的
const allowedGroups = groupConfigs.value.filter((c) => c.isExclusive && c.isSelected).map((c) => c.groupId)
// 构建 group_rates
// - 有新专属倍率: 设置为该值
// - 原本有专属倍率但现在被清空: 设置为 null表示删除
const groupRates: Record<number, number | null> = {}
for (const c of groupConfigs.value) {
const hadOriginalRate = originalGroupRates.value[c.groupId] !== undefined
if (c.customRate !== null) {
// 有专属倍率
groupRates[c.groupId] = c.customRate
} else if (hadOriginalRate) {
// 原本有专属倍率,现在被清空,需要显式删除
groupRates[c.groupId] = null
}
}
await adminAPI.users.update(props.user.id, {
allowed_groups: allowedGroups,
group_rates: Object.keys(groupRates).length > 0 ? groupRates : undefined,
})
appStore.showSuccess(t('admin.users.groupConfigUpdated'))
emit('success')
emit('close')
} catch (error) {
console.error('Failed to update user group config:', error)
} finally {
submitting.value = false
}
}
</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,253 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.userApiKeys')" width="wide" @close="handleClose">
<div v-if="user" class="space-y-4">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">{{ user.email.charAt(0).toUpperCase() }}</span>
</div>
<div><p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p><p class="text-sm text-gray-500 dark:text-dark-400">{{ user.username }}</p></div>
</div>
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 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-if="apiKeys.length === 0" class="py-8 text-center"><p class="text-sm text-gray-500">{{ t('admin.users.noApiKeys') }}</p></div>
<div v-else ref="scrollContainerRef" class="max-h-96 space-y-3 overflow-y-auto" @scroll="closeGroupSelector">
<div v-for="key in apiKeys" :key="key.id" class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2"><span class="font-medium text-gray-900 dark:text-white">{{ key.name }}</span><span :class="['badge text-xs', key.status === 'active' ? 'badge-success' : 'badge-danger']">{{ key.status }}</span></div>
<p class="truncate font-mono text-sm text-gray-500">{{ key.key.substring(0, 20) }}...{{ key.key.substring(key.key.length - 8) }}</p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-xs text-gray-500">
<div class="flex items-center gap-1">
<span>{{ t('admin.users.group') }}:</span>
<button
:ref="(el) => setGroupButtonRef(key.id, el)"
@click="openGroupSelector(key)"
class="-mx-1 -my-0.5 flex cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:disabled="updatingKeyIds.has(key.id)"
>
<GroupBadge
v-if="key.group_id && key.group"
:name="key.group.name"
:platform="key.group.platform"
:subscription-type="key.group.subscription_type"
:rate-multiplier="key.group.rate_multiplier"
/>
<span v-else class="text-gray-400 italic">{{ t('admin.users.none') }}</span>
<svg v-if="updatingKeyIds.has(key.id)" class="h-3 w-3 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>
<svg v-else class="h-3 w-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /></svg>
</button>
</div>
<div class="flex items-center gap-1"><span>{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span></div>
</div>
</div>
</div>
</div>
</BaseDialog>
<!-- Group Selector Dropdown -->
<Teleport to="body">
<div
v-if="groupSelectorKeyId !== null && dropdownPosition"
ref="dropdownRef"
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div class="max-h-64 overflow-y-auto p-1.5">
<!-- Unbind option -->
<button
@click="changeGroup(selectedKeyForGroup!, null)"
:class="[
'flex w-full items-center rounded-lg px-3 py-2 text-sm transition-colors',
!selectedKeyForGroup?.group_id
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
>
<span class="text-gray-500 italic">{{ t('admin.users.none') }}</span>
<svg
v-if="!selectedKeyForGroup?.group_id"
class="ml-auto h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
</button>
<!-- Group options -->
<button
v-for="group in allGroups"
:key="group.id"
@click="changeGroup(selectedKeyForGroup!, group.id)"
:class="[
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors',
selectedKeyForGroup?.group_id === group.id
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
>
<GroupOptionItem
:name="group.name"
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
:description="group.description"
:selected="selectedKeyForGroup?.group_id === group.id"
/>
</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { AdminUser, AdminGroup, ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
const emit = defineEmits(['close'])
const { t } = useI18n()
const appStore = useAppStore()
const apiKeys = ref<ApiKey[]>([])
const allGroups = ref<AdminGroup[]>([])
const loading = ref(false)
const updatingKeyIds = ref(new Set<number>())
const groupSelectorKeyId = ref<number | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const scrollContainerRef = ref<HTMLElement | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const selectedKeyForGroup = computed(() => {
if (groupSelectorKeyId.value === null) return null
return apiKeys.value.find((k) => k.id === groupSelectorKeyId.value) || null
})
const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
groupButtonRefs.value.set(keyId, el)
} else {
groupButtonRefs.value.delete(keyId)
}
}
watch(() => props.show, (v) => {
if (v && props.user) {
load()
loadGroups()
} else {
closeGroupSelector()
}
})
const load = async () => {
if (!props.user) return
loading.value = true
groupButtonRefs.value.clear()
try {
const res = await adminAPI.users.getUserApiKeys(props.user.id)
apiKeys.value = res.items || []
} catch (error) {
console.error('Failed to load API keys:', error)
} finally {
loading.value = false
}
}
const loadGroups = async () => {
try {
const groups = await adminAPI.groups.getAll()
allGroups.value = groups
} catch (error) {
console.error('Failed to load groups:', error)
}
}
const DROPDOWN_HEIGHT = 272 // max-h-64 = 16rem = 256px + padding
const DROPDOWN_GAP = 4
const openGroupSelector = (key: ApiKey) => {
if (groupSelectorKeyId.value === key.id) {
closeGroupSelector()
} else {
const buttonEl = groupButtonRefs.value.get(key.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const openUpward = spaceBelow < DROPDOWN_HEIGHT && rect.top > spaceBelow
dropdownPosition.value = {
top: openUpward ? rect.top - DROPDOWN_HEIGHT - DROPDOWN_GAP : rect.bottom + DROPDOWN_GAP,
left: rect.left
}
}
groupSelectorKeyId.value = key.id
}
}
const closeGroupSelector = () => {
groupSelectorKeyId.value = null
dropdownPosition.value = null
}
const changeGroup = async (key: ApiKey, newGroupId: number | null) => {
closeGroupSelector()
if (key.group_id === newGroupId || (!key.group_id && newGroupId === null)) return
updatingKeyIds.value.add(key.id)
try {
const result = await adminAPI.apiKeys.updateApiKeyGroup(key.id, newGroupId)
// Update local data
const idx = apiKeys.value.findIndex((k) => k.id === key.id)
if (idx !== -1) {
apiKeys.value[idx] = result.api_key
}
if (result.auto_granted_group_access && result.granted_group_name) {
appStore.showSuccess(t('admin.users.groupChangedWithGrant', { group: result.granted_group_name }))
} else {
appStore.showSuccess(t('admin.users.groupChangedSuccess'))
}
} catch (error: any) {
appStore.showError(error?.message || t('admin.users.groupChangeFailed'))
} finally {
updatingKeyIds.value.delete(key.id)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && groupSelectorKeyId.value !== null) {
event.stopPropagation()
closeGroupSelector()
}
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (dropdownRef.value && !dropdownRef.value.contains(target)) {
// Check if the click is on one of the group trigger buttons
for (const el of groupButtonRefs.value.values()) {
if (el.contains(target)) return
}
closeGroupSelector()
}
}
const handleClose = () => {
closeGroupSelector()
emit('close')
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeyDown, true)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown, true)
})
</script>

View File

@@ -0,0 +1,322 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.balanceHistoryTitle')" width="wide" :close-on-click-outside="true" :z-index="40" @close="$emit('close')">
<div v-if="user" class="space-y-4">
<!-- User header: two-row layout with full user info -->
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<!-- Row 1: avatar + email/username/created_at (left) + current balance (right) -->
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">
{{ user.email.charAt(0).toUpperCase() }}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
<span
v-if="user.username"
class="flex-shrink-0 rounded bg-primary-50 px-1.5 py-0.5 text-xs text-primary-600 dark:bg-primary-900/20 dark:text-primary-400"
>
{{ user.username }}
</span>
</div>
<p class="text-xs text-gray-400 dark:text-dark-500">
{{ t('admin.users.createdAt') }}: {{ formatDateTime(user.created_at) }}
</p>
</div>
<!-- Current balance: prominent display on the right -->
<div class="flex-shrink-0 text-right">
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('admin.users.currentBalance') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
${{ user.balance?.toFixed(2) || '0.00' }}
</p>
</div>
</div>
<!-- Row 2: notes + total recharged -->
<div class="mt-2.5 flex items-center justify-between border-t border-gray-200/60 pt-2.5 dark:border-dark-600/60">
<p class="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-dark-400" :title="user.notes || ''">
<template v-if="user.notes">{{ t('admin.users.notes') }}: {{ user.notes }}</template>
<template v-else>&nbsp;</template>
</p>
<p class="ml-4 flex-shrink-0 text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.users.totalRecharged') }}: <span class="font-semibold text-emerald-600 dark:text-emerald-400">${{ totalRecharged.toFixed(2) }}</span>
</p>
</div>
</div>
<!-- Type filter + Action buttons -->
<div class="flex items-center gap-3">
<Select
v-model="typeFilter"
:options="typeOptions"
class="w-56"
@change="loadHistory(1)"
/>
<!-- Deposit button - matches menu style -->
<button
v-if="!hideActions"
@click="emit('deposit')"
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
>
<Icon name="plus" size="sm" class="text-emerald-500" :stroke-width="2" />
{{ t('admin.users.deposit') }}
</button>
<!-- Withdraw button - matches menu style -->
<button
v-if="!hideActions"
@click="emit('withdraw')"
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
{{ t('admin.users.withdraw') }}
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-8">
<svg class="h-8 w-8 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" />
<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" />
</svg>
</div>
<!-- Empty state -->
<div v-else-if="history.length === 0" class="py-8 text-center">
<p class="text-sm text-gray-500">{{ t('admin.users.noBalanceHistory') }}</p>
</div>
<!-- History list -->
<div v-else class="max-h-[28rem] space-y-3 overflow-y-auto">
<div
v-for="item in history"
:key="item.id"
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div class="flex items-start justify-between">
<!-- Left: type icon + description -->
<div class="flex items-start gap-3">
<div
:class="[
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg',
getIconBg(item)
]"
>
<Icon :name="getIconName(item)" size="sm" :class="getIconColor(item)" />
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ getItemTitle(item) }}
</p>
<!-- Notes (admin adjustment reason) -->
<p
v-if="item.notes"
class="mt-0.5 text-xs text-gray-500 dark:text-dark-400"
:title="item.notes"
>
{{ item.notes.length > 60 ? item.notes.substring(0, 55) + '...' : item.notes }}
</p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-dark-500">
{{ formatDateTime(item.used_at || item.created_at) }}
</p>
</div>
</div>
<!-- Right: value -->
<div class="text-right">
<p :class="['text-sm font-semibold', getValueColor(item)]">
{{ formatValue(item) }}
</p>
<p
v-if="isAdminType(item.type)"
class="text-xs text-gray-400 dark:text-dark-500"
>
{{ t('redeem.adminAdjustment') }}
</p>
<p
v-else
class="font-mono text-xs text-gray-400 dark:text-dark-500"
>
{{ item.code.slice(0, 8) }}...
</p>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-2">
<button
:disabled="currentPage <= 1"
class="btn btn-secondary px-3 py-1 text-sm"
@click="loadHistory(currentPage - 1)"
>
{{ t('pagination.previous') }}
</button>
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ currentPage }} / {{ totalPages }}
</span>
<button
:disabled="currentPage >= totalPages"
class="btn btn-secondary px-3 py-1 text-sm"
@click="loadHistory(currentPage + 1)"
>
{{ t('pagination.next') }}
</button>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI, type BalanceHistoryItem } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { AdminUser } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean; user: AdminUser | null; hideActions?: boolean }>()
const emit = defineEmits(['close', 'deposit', 'withdraw'])
const { t } = useI18n()
const history = ref<BalanceHistoryItem[]>([])
const loading = ref(false)
const currentPage = ref(1)
const total = ref(0)
const totalRecharged = ref(0)
const pageSize = 15
const typeFilter = ref('')
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
// Type filter options
const typeOptions = computed(() => [
{ value: '', label: t('admin.users.allTypes') },
{ value: 'balance', label: t('admin.users.typeBalance') },
{ value: 'admin_balance', label: t('admin.users.typeAdminBalance') },
{ value: 'concurrency', label: t('admin.users.typeConcurrency') },
{ value: 'admin_concurrency', label: t('admin.users.typeAdminConcurrency') },
{ value: 'subscription', label: t('admin.users.typeSubscription') }
])
// Watch modal open
watch(() => props.show, (v) => {
if (v && props.user) {
typeFilter.value = ''
loadHistory(1)
}
})
const loadHistory = async (page: number) => {
if (!props.user) return
loading.value = true
currentPage.value = page
try {
const res = await adminAPI.users.getUserBalanceHistory(
props.user.id,
page,
pageSize,
typeFilter.value || undefined
)
history.value = res.items || []
total.value = res.total || 0
totalRecharged.value = res.total_recharged || 0
} catch (error) {
console.error('Failed to load balance history:', error)
} finally {
loading.value = false
}
}
// Helper: check if admin type
const isAdminType = (type: string) => type === 'admin_balance' || type === 'admin_concurrency'
// Helper: check if balance type (includes admin_balance)
const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance'
// Helper: check if subscription type
const isSubscriptionType = (type: string) => type === 'subscription'
// Icon name based on type
const getIconName = (item: BalanceHistoryItem) => {
if (isBalanceType(item.type)) return 'dollar'
if (isSubscriptionType(item.type)) return 'badge'
return 'bolt' // concurrency
}
// Icon background color
const getIconBg = (item: BalanceHistoryItem) => {
if (isBalanceType(item.type)) {
return item.value >= 0
? 'bg-emerald-100 dark:bg-emerald-900/30'
: 'bg-red-100 dark:bg-red-900/30'
}
if (isSubscriptionType(item.type)) return 'bg-purple-100 dark:bg-purple-900/30'
return item.value >= 0
? 'bg-blue-100 dark:bg-blue-900/30'
: 'bg-orange-100 dark:bg-orange-900/30'
}
// Icon text color
const getIconColor = (item: BalanceHistoryItem) => {
if (isBalanceType(item.type)) {
return item.value >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
}
if (isSubscriptionType(item.type)) return 'text-purple-600 dark:text-purple-400'
return item.value >= 0
? 'text-blue-600 dark:text-blue-400'
: 'text-orange-600 dark:text-orange-400'
}
// Value text color
const getValueColor = (item: BalanceHistoryItem) => {
if (isBalanceType(item.type)) {
return item.value >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
}
if (isSubscriptionType(item.type)) return 'text-purple-600 dark:text-purple-400'
return item.value >= 0
? 'text-blue-600 dark:text-blue-400'
: 'text-orange-600 dark:text-orange-400'
}
// Item title
const getItemTitle = (item: BalanceHistoryItem) => {
switch (item.type) {
case 'balance':
return t('redeem.balanceAddedRedeem')
case 'admin_balance':
return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin')
case 'concurrency':
return t('redeem.concurrencyAddedRedeem')
case 'admin_concurrency':
return item.value >= 0 ? t('redeem.concurrencyAddedAdmin') : t('redeem.concurrencyReducedAdmin')
case 'subscription':
return t('redeem.subscriptionAssigned')
default:
return t('common.unknown')
}
}
// Format display value
const formatValue = (item: BalanceHistoryItem) => {
if (isBalanceType(item.type)) {
const sign = item.value >= 0 ? '+' : ''
return `${sign}$${item.value.toFixed(2)}`
}
if (isSubscriptionType(item.type)) {
const days = item.validity_days || Math.round(item.value)
const groupName = item.group?.name || ''
return groupName ? `${days}d - ${groupName}` : `${days}d`
}
// concurrency types
const sign = item.value >= 0 ? '+' : ''
return `${sign}${item.value}`
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<BaseDialog :show="show" :title="operation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')" width="narrow" @close="$emit('close')">
<form v-if="user" id="balance-form" @submit.prevent="handleBalanceSubmit" class="space-y-5">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"><span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span></div>
<div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ formatBalance(user.balance) }}</p></div>
</div>
<div>
<label class="input-label">{{ operation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}</label>
<div class="relative flex gap-2">
<div class="relative flex-1"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="any" min="0" required class="input pl-8" /></div>
<button v-if="operation === 'subtract'" type="button" @click="fillAllBalance" class="btn btn-secondary whitespace-nowrap">{{ t('admin.users.withdrawAll') }}</button>
</div>
</div>
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"><div class="flex items-center justify-between text-sm"><span class="text-gray-700 dark:text-gray-300">{{ t('admin.users.newBalance') }}:</span><span class="font-bold text-gray-900 dark:text-gray-100">${{ formatBalance(calculateNewBalance()) }}</span></div></div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="balance-form" :disabled="submitting || !form.amount" class="btn" :class="operation === 'add' ? 'bg-emerald-600 text-white' : 'btn-danger'">{{ submitting ? t('common.saving') : t('common.confirm') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { AdminUser } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: AdminUser | null, operation: 'add' | 'subtract' }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } })
// 格式化余额显示完整精度去除尾部多余的0
const formatBalance = (value: number) => {
if (value === 0) return '0.00'
// 最多保留8位小数去除尾部的0
const formatted = value.toFixed(8).replace(/\.?0+$/, '')
// 确保至少有2位小数
const parts = formatted.split('.')
if (parts.length === 1) return formatted + '.00'
if (parts[1].length === 1) return formatted + '0'
return formatted
}
// 填入全部余额
const fillAllBalance = () => {
if (props.user) {
form.amount = props.user.balance
}
}
const calculateNewBalance = () => {
if (!props.user) return 0
const result = props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount
// 避免浮点数精度问题导致的 -0.00 显示
return Math.abs(result) < 1e-10 ? 0 : result
}
const handleBalanceSubmit = async () => {
if (!props.user) return
if (!form.amount || form.amount <= 0) {
appStore.showError(t('admin.users.amountRequired'))
return
}
// 退款时验证金额不超过实际余额
if (props.operation === 'subtract' && form.amount > props.user.balance) {
appStore.showError(t('admin.users.insufficientBalance'))
return
}
submitting.value = true
try {
await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes)
appStore.showSuccess(t('common.success')); emit('success'); emit('close')
} catch (e: any) {
console.error('Failed to update balance:', e)
appStore.showError(e.response?.data?.detail || t('common.error'))
} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.createUser')"
width="normal"
@close="$emit('close')"
>
<form id="create-user-form" @submit.prevent="submit" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="form.email" type="email" required class="input" :placeholder="t('admin.users.enterEmail')" />
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input v-model="form.password" type="text" required class="input pr-10" :placeholder="t('admin.users.enterPassword')" />
</div>
<button type="button" @click="generateRandomPassword" class="btn btn-secondary px-3">
<Icon name="refresh" size="md" />
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="create-user-form" :disabled="loading" class="btn btn-primary">
{{ loading ? t('admin.users.creating') : t('common.create') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
import { useForm } from '@/composables/useForm'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 })
const { loading, submit } = useForm({
form,
submitFn: async (data) => {
await adminAPI.users.create(data)
emit('success'); emit('close')
},
successMsg: t('admin.users.userCreated')
})
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 }) })
const generateRandomPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
form.password = p
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.editUser')"
width="normal"
@close="$emit('close')"
>
<form v-if="user" id="edit-user-form" @submit.prevent="handleUpdateUser" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="form.email" type="email" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input v-model="form.password" type="text" class="input pr-10" :placeholder="t('admin.users.enterNewPassword')" />
<button v-if="form.password" type="button" @click="copyPassword" class="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="passwordCopied ? 'text-green-500' : 'text-gray-400'">
<svg v-if="passwordCopied" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" 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>
</button>
</div>
<button type="button" @click="generatePassword" class="btn btn-secondary px-3">
<Icon name="refresh" size="md" />
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.notes') }}</label>
<textarea v-model="form.notes" rows="3" class="input"></textarea>
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.soraStorageQuota') }}</label>
<div class="flex items-center gap-2">
<input v-model.number="form.sora_storage_quota_gb" type="number" min="0" step="0.1" class="input" placeholder="0" />
<span class="shrink-0 text-sm text-gray-500">GB</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.users.soraStorageQuotaHint') }}</p>
</div>
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="edit-user-form" :disabled="submitting" class="btn btn-primary">
{{ submitting ? t('admin.users.updating') : t('common.update') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { AdminUser, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, sora_storage_quota_gb: 0, customAttributes: {} as UserAttributeValuesMap })
watch(() => props.user, (u) => {
if (u) {
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, sora_storage_quota_gb: Number(((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024)).toFixed(2)), customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
form.password = p
}
const copyPassword = async () => {
if (form.password && await copyToClipboard(form.password, t('admin.users.passwordCopied'))) {
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
}
}
const handleUpdateUser = async () => {
if (!props.user) return
if (!form.email.trim()) {
appStore.showError(t('admin.users.emailRequired'))
return
}
if (form.concurrency < 1) {
appStore.showError(t('admin.users.concurrencyMin'))
return
}
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, sora_storage_quota_bytes: Math.round((form.sora_storage_quota_gb || 0) * 1024 * 1024 * 1024) }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
appStore.showSuccess(t('admin.users.userUpdated'))
emit('success'); emit('close')
} catch (e: any) {
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div class="space-y-4">
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
<svg
class="icon mr-2"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
style="color: rgb(233, 84, 32); width: 20px; height: 20px"
aria-hidden="true"
>
<g id="linuxdo_icon" data-name="linuxdo_icon">
<path
d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
fill="#EFEFEF"
></path>
<path
d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z"
fill="#FEB005"
></path>
<path
d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z"
fill="#1D1D1F"
></path>
</g>
</svg>
{{ t('auth.linuxdo.signIn') }}
</button>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.linuxdo.orContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
defineProps<{
disabled?: boolean
}>()
const route = useRoute()
const { t } = useI18n()
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
window.location.href = startURL
}
</script>

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