Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s

This commit is contained in:
李建琦
2026-08-21 18:30:13 +08:00
commit 6d655c9903
3584 changed files with 1270640 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
<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 AdminComplianceDialog from '@/components/admin/AdminComplianceDialog.vue'
import { resolveRouteDocumentTitle } from '@/router/title'
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore, useAdminComplianceStore, useAdminSettingsStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
import { updateFavicon } from '@/utils/branding'
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const announcementStore = useAnnouncementStore()
const adminComplianceStore = useAdminComplianceStore()
const adminSettingsStore = useAdminSettingsStore()
function updateDocumentTitle() {
const customMenuItems = [
...(appStore.cachedPublicSettings?.custom_menu_items ?? []),
...(authStore.isAdmin ? adminSettingsStore.customMenuItems : []),
]
document.title = resolveRouteDocumentTitle(route, appStore.siteName, customMenuItems)
}
// Watch for site settings changes and update favicon/title
watch(
() => appStore.siteLogo,
(newLogo) => {
if (newLogo) {
updateFavicon(newLogo)
}
},
{ immediate: true }
)
watch(
[
() => route.fullPath,
() => route.meta.title,
() => route.meta.titleKey,
() => appStore.siteName,
() => appStore.cachedPublicSettings?.custom_menu_items,
() => authStore.isAdmin,
() => adminSettingsStore.customMenuItems,
],
updateDocumentTitle,
{ deep: true }
)
// Watch for authentication state and manage subscription data + announcements
function onVisibilityChange() {
if (document.visibilityState === 'visible' && authStore.isAuthenticated) {
announcementStore.fetchAnnouncements()
}
}
function onAdminComplianceRequired(event: Event) {
const detail = (event as CustomEvent<Record<string, string>>).detail || {}
adminComplianceStore.requireAcknowledgement(detail)
}
watch(
() => authStore.isAuthenticated,
(isAuthenticated, oldValue) => {
if (isAuthenticated) {
if (authStore.isAdmin) {
adminComplianceStore.fetchStatus().catch((error) => {
console.error('Failed to fetch admin compliance status:', error)
})
}
// 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()
adminComplianceStore.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)
window.removeEventListener('admin-compliance-required', onAdminComplianceRequired)
})
onMounted(async () => {
window.addEventListener('admin-compliance-required', onAdminComplianceRequired)
// 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()
// Re-resolve document title now that site settings are available
updateDocumentTitle()
})
</script>
<template>
<NavigationProgress />
<RouterView />
<Toast />
<AnnouncementPopup />
<AdminComplianceDialog />
</template>
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
const showError = vi.fn()
const showSuccess = vi.fn()
const showWarning = vi.fn()
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess,
showWarning
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
importData: vi.fn()
}
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const mountModal = () =>
mount(ImportDataModal, {
props: { show: true },
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
}
}
})
const makeJsonFile = (name: string, content: string, type = 'application/json') => {
const file = new File([content], name, { type })
Object.defineProperty(file, 'text', {
value: () => Promise.resolve(content)
})
return file
}
const setInputFiles = (element: Element, files: File[]) => {
Object.defineProperty(element, 'files', {
value: files,
configurable: true
})
}
describe('ImportDataModal', () => {
beforeEach(async () => {
showError.mockReset()
showSuccess.mockReset()
showWarning.mockReset()
const { adminAPI } = await import('@/api/admin')
vi.mocked(adminAPI.accounts.importData).mockReset()
})
it('未选择文件时提示错误', async () => {
const wrapper = mountModal()
await wrapper.find('form').trigger('submit')
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
})
it('无效 JSON 时按文件名提示解析失败', async () => {
const { adminAPI } = await import('@/api/admin')
const wrapper = mountModal()
const input = wrapper.find('input[type="file"]')
setInputFiles(input.element, [makeJsonFile('data.json', 'invalid json')])
await input.trigger('change')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailedFile')
expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
})
it('不是导出数据的 JSON 按文件名拒绝', async () => {
const { adminAPI } = await import('@/api/admin')
const wrapper = mountModal()
const input = wrapper.find('input[type="file"]')
setInputFiles(input.element, [makeJsonFile('random.json', JSON.stringify({ name: 'test' }))])
await input.trigger('change')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportInvalidFile')
expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
})
it('无有效 JSON 的选择不清空已有选择', async () => {
const { adminAPI } = await import('@/api/admin')
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
proxy_created: 0,
proxy_reused: 0,
proxy_failed: 0,
account_created: 1,
account_failed: 0
})
const wrapper = mountModal()
const input = wrapper.find('input[type="file"]')
const valid = makeJsonFile(
'valid.json',
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
)
setInputFiles(input.element, [valid])
await input.trigger('change')
setInputFiles(input.element, [new File(['hello'], 'notes.txt', { type: 'text/plain' })])
await input.trigger('change')
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
data: expect.objectContaining({
accounts: [{ name: 'a' }]
}),
skip_default_group_bind: true
})
})
it('merges multiple selected JSON files before importing', async () => {
const { adminAPI } = await import('@/api/admin')
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
proxy_created: 0,
proxy_reused: 0,
proxy_failed: 0,
account_created: 2,
account_failed: 0
})
const wrapper = mountModal()
const input = wrapper.find('input[type="file"]')
const first = makeJsonFile(
'first.json',
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
)
const second = makeJsonFile(
'second.json',
JSON.stringify({
exported_at: '2026-07-05T00:00:01Z',
proxies: [{ proxy_key: 'p' }],
accounts: [{ name: 'b' }]
})
)
setInputFiles(input.element, [first, second])
await input.trigger('change')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
data: expect.objectContaining({
proxies: [{ proxy_key: 'p' }],
accounts: [{ name: 'a' }, { name: 'b' }]
}),
skip_default_group_bind: true
})
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.dataImportSuccess')
})
it('部分成功时关闭弹窗仍通知父组件刷新', async () => {
const { adminAPI } = await import('@/api/admin')
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
proxy_created: 0,
proxy_reused: 0,
proxy_failed: 0,
account_created: 1,
account_failed: 1
})
const wrapper = mountModal()
const input = wrapper.find('input[type="file"]')
setInputFiles(input.element, [
makeJsonFile(
'mixed.json',
JSON.stringify({
exported_at: '2026-07-05T00:00:00Z',
proxies: [],
accounts: [{ name: 'a' }, { name: 'b' }]
})
)
])
await input.trigger('change')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportCompletedWithErrors')
expect(wrapper.emitted('imported')).toBeUndefined()
// 第二个 btn-secondary 是 footer 的取消按钮(第一个是选择文件)
await wrapper.findAll('button.btn-secondary')[1]!.trigger('click')
expect(wrapper.emitted('imported')).toHaveLength(1)
expect(wrapper.emitted('close')).toHaveLength(1)
})
})
@@ -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()
})
})
})
@@ -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')
})
})
+98
View File
@@ -0,0 +1,98 @@
/**
* Vitest 测试环境设置
* 提供全局 mock 和测试工具
*/
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear() {
values.clear()
},
getItem(key: string) {
return values.has(key) ? values.get(key)! : null
},
key(index: number) {
return Array.from(values.keys())[index] ?? null
},
removeItem(key: string) {
values.delete(key)
},
setItem(key: string, value: string) {
values.set(key, String(value))
}
}
}
if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.getItem !== 'function') {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: createMemoryStorage()
})
}
if (typeof window !== 'undefined' && typeof window.localStorage.getItem !== 'function') {
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: globalThis.localStorage
})
}
// 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 matchMedia (jsdom 未实现;DataTable 等组件依赖它做桌面/移动分支)
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
window.matchMedia = ((query: string) => ({
matches: true, // 测试默认按桌面视口渲染表格
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia
}
// 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 })
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import { duplicate } from '@/api/admin/accounts'
describe('admin account duplicate API', () => {
beforeEach(() => {
sessionStorage.clear()
post.mockReset()
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
})
it('sends a stable idempotency key with the duplicate request', async () => {
const account = await duplicate(42)
expect(post).toHaveBeenCalledWith('/admin/accounts/42/duplicate', undefined, {
headers: {
'Idempotency-Key': 'account-duplicate-42-11111111-1111-4111-8111-111111111111'
}
})
expect(account).toEqual({ id: 43, name: 'primary (Copy)' })
})
it('reuses the operation key after an ambiguous failed request', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(99)).rejects.toThrow('network timeout')
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
await duplicate(99)
expect(post).toHaveBeenCalledTimes(2)
const firstHeaders = post.mock.calls[0][2].headers
const secondHeaders = post.mock.calls[1][2].headers
expect(secondHeaders).toEqual(firstHeaders)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/accounts')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post, put, del } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { get, post, put, delete: del }
}))
import {
deleteOllamaCloudUsageSession,
getOllamaCloudUsage,
getOllamaCloudUsageSettings,
refreshOllamaCloudUsage,
saveOllamaCloudUsageSession,
setOllamaCloudUsageAutoRefresh,
updateOllamaCloudUsageSettings
} from '@/api/admin/accounts'
const state = {
account_id: 7,
eligible: true,
configured: true,
auto_refresh_enabled: false,
encryption_key_configured: true
}
describe('admin Ollama Cloud usage API', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
put.mockReset()
del.mockReset()
})
it('uses dedicated global settings endpoints', async () => {
const settings = { enabled: false, interval_minutes: 60, debounce_minutes: 1 }
get.mockResolvedValueOnce({ data: settings })
put.mockResolvedValueOnce({ data: settings })
await expect(getOllamaCloudUsageSettings()).resolves.toEqual(settings)
await expect(updateOllamaCloudUsageSettings(settings)).resolves.toEqual(settings)
expect(get).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings')
expect(put).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings', settings)
})
it('keeps session configuration write-only and separate from account updates', async () => {
get.mockResolvedValueOnce({ data: state })
put.mockResolvedValueOnce({ data: state }).mockResolvedValueOnce({ data: state })
del.mockResolvedValueOnce({ data: { ...state, configured: false } })
post.mockResolvedValueOnce({ data: state })
await expect(getOllamaCloudUsage(7)).resolves.toEqual(state)
await expect(saveOllamaCloudUsageSession(7, 'wos-session=secret')).resolves.toEqual(state)
await expect(setOllamaCloudUsageAutoRefresh(7, true)).resolves.toEqual(state)
await expect(refreshOllamaCloudUsage(7)).resolves.toEqual(state)
await expect(deleteOllamaCloudUsageSession(7)).resolves.toMatchObject({ configured: false })
expect(put).toHaveBeenNthCalledWith(1, '/admin/accounts/7/ollama-cloud-usage/session', { session: 'wos-session=secret' })
expect(put).toHaveBeenNthCalledWith(2, '/admin/accounts/7/ollama-cloud-usage/auto-refresh', { enabled: true })
expect(post).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/refresh')
expect(del).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/session')
})
})
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post, put } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { get, post, put }
}))
import {
getUpstreamBillingProbeSettings,
probeUpstreamBilling,
probeUpstreamBillingBatch,
setUpstreamBillingProbeEnabled,
updateUpstreamBillingProbeSettings
} from '@/api/admin/accounts'
describe('admin account upstream billing probe API', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
put.mockReset()
})
it('reads and updates global settings', async () => {
const settings = { enabled: true, interval_minutes: 30 }
get.mockResolvedValueOnce({ data: settings })
put.mockResolvedValueOnce({ data: settings })
await expect(getUpstreamBillingProbeSettings()).resolves.toEqual(settings)
await expect(updateUpstreamBillingProbeSettings(settings)).resolves.toEqual(settings)
expect(get).toHaveBeenCalledWith('/admin/accounts/upstream-billing-probe/settings')
expect(put).toHaveBeenCalledWith('/admin/accounts/upstream-billing-probe/settings', settings)
})
it('uses dedicated account and batch endpoints', async () => {
const result = { account_id: 7, snapshot: { status: 'unsupported' } }
put.mockResolvedValueOnce({ data: {} })
post.mockResolvedValueOnce({ data: result })
post.mockResolvedValueOnce({ data: { results: [result] } })
await setUpstreamBillingProbeEnabled(7, true)
await expect(probeUpstreamBilling(7)).resolves.toEqual(result)
await expect(probeUpstreamBillingBatch([7])).resolves.toEqual([result])
expect(put).toHaveBeenCalledWith('/admin/accounts/7/upstream-billing-probe', { enabled: true })
expect(post).toHaveBeenNthCalledWith(1, '/admin/accounts/7/upstream-billing-probe')
expect(post).toHaveBeenNthCalledWith(2, '/admin/accounts/upstream-billing-probe/batch', { account_ids: [7] })
})
})
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: { post },
}))
import { duplicate } from '@/api/admin/channelMonitor'
describe('admin channel monitor duplicate API', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
post.mockReset()
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('sends a stable idempotency key with the duplicate request', async () => {
const monitor = await duplicate(42)
expect(post).toHaveBeenCalledWith('/admin/channel-monitors/42/duplicate', undefined, {
headers: {
'Idempotency-Key': 'channel-monitor-duplicate-7-42-11111111-1111-4111-8111-111111111111',
},
})
expect(monitor).toEqual({ id: 43, name: 'primary (Copy)' })
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after an ambiguous failed request', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(99)).rejects.toThrow('network timeout')
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
await duplicate(99)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/channelMonitor')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
it('does not reuse an operation key across administrators for the same monitor', async () => {
post.mockRejectedValueOnce(new Error('first admin timeout'))
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
const firstAdminHeaders = post.mock.calls[0][2].headers
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'22222222-2222-4222-8222-222222222222'
)
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
await duplicate(55)
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
expect(post.mock.calls[1][2].headers).toEqual({
'Idempotency-Key': 'channel-monitor-duplicate-8-55-22222222-2222-4222-8222-222222222222',
})
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:7:55')).toBe(
firstAdminHeaders['Idempotency-Key']
)
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:8:55')).toBeNull()
})
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
localStorage.setItem('auth_user', '{invalid json')
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(66)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'33333333-3333-4333-8333-333333333333'
)
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
await duplicate(66)
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: { post },
}))
import { authorizePassword, createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
describe('admin Grok SSO import API', () => {
beforeEach(() => {
post.mockReset()
post.mockResolvedValue({ data: { created: [], failed: [] } })
})
it.each([
[1, 180_000],
[3, 180_000],
[4, 270_000],
[7, 360_000],
])('uses a timeout sized for %i keys', async (keyCount, expectedTimeout) => {
expect(getGrokSSOImportTimeout(keyCount)).toBe(expectedTimeout)
await createFromSSO({
sso_tokens: Array.from({ length: keyCount }, (_, index) => `sso-${index + 1}`),
})
expect(post).toHaveBeenCalledWith(
'/admin/grok/sso-to-oauth',
expect.objectContaining({ sso_tokens: expect.any(Array) }),
{ timeout: expectedTimeout },
)
})
it('preserves password whitespace and applies the authorization timeout', async () => {
post.mockResolvedValueOnce({ data: { access_token: 'access-token' } })
await authorizePassword(' user@example.com ---- password with spaces ', 7)
expect(post).toHaveBeenCalledWith(
'/admin/grok/oauth/password',
{
email: 'user@example.com',
password: ' password with spaces ',
proxy_id: 7,
},
{ timeout: 120_000 },
)
})
})
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import { duplicate } from '@/api/admin/groups'
describe('admin group duplicate API', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
post.mockReset()
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)', status: 'inactive' } })
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('sends a stable idempotency key with the duplicate request', async () => {
const group = await duplicate(42)
expect(post).toHaveBeenCalledWith('/admin/groups/42/duplicate', undefined, {
headers: {
'Idempotency-Key': 'group-duplicate-7-42-11111111-1111-4111-8111-111111111111'
}
})
expect(group).toEqual({ id: 43, name: 'primary (Copy)', status: 'inactive' })
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after an ambiguous failed request', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(99)).rejects.toThrow('network timeout')
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
await duplicate(99)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
expect(sessionStorage.length).toBe(0)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/groups')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
it('does not reuse an operation key across administrators for the same group', async () => {
post.mockRejectedValueOnce(new Error('first admin timeout'))
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
const firstAdminHeaders = post.mock.calls[0][2].headers
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'22222222-2222-4222-8222-222222222222'
)
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
await duplicate(55)
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
expect(post.mock.calls[1][2].headers).toEqual({
'Idempotency-Key': 'group-duplicate-8-55-22222222-2222-4222-8222-222222222222'
})
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:7:55')).toBe(
firstAdminHeaders['Idempotency-Key']
)
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:8:55')).toBeNull()
})
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
localStorage.setItem('auth_user', '{invalid json')
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(66)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
'33333333-3333-4333-8333-333333333333'
)
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
await duplicate(66)
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get } = vi.hoisted(() => ({
get: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: { get },
}))
import { getUsageSummary } from '@/api/admin/groups'
describe('admin group usage summary API', () => {
beforeEach(() => {
get.mockReset()
get.mockResolvedValue({ data: [] })
})
it('does not send browser timezone parameters', async () => {
const summary = [
{ group_id: 1, today_cost: 1.25, yesterday_cost: 2.5, total_cost: 9.75 },
]
get.mockResolvedValue({ data: summary })
await expect(getUsageSummary()).resolves.toEqual(summary)
expect(get).toHaveBeenCalledWith('/admin/groups/usage-summary')
})
})
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}))
vi.mock('../client', () => ({
apiClient: {
get,
post,
},
}))
import { getRollbackVersions, rollback, type RollbackVersionInfo } from '@/api/admin/system'
describe('admin system rollback API', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
})
it('getRollbackVersions fetches the rollback version list', async () => {
const versions: RollbackVersionInfo[] = [
{
version: '0.1.146',
published_at: '2026-07-07T00:00:00Z',
html_url: 'https://github.com/Wei-Shaw/sub2api/releases/tag/v0.1.146'
}
]
get.mockResolvedValue({ data: { versions } })
const result = await getRollbackVersions()
expect(get).toHaveBeenCalledWith('/admin/system/rollback-versions')
expect(result.versions).toEqual(versions)
})
it('rollback posts the target version in the request body', async () => {
post.mockResolvedValue({ data: { message: 'ok', need_restart: true } })
const result = await rollback('0.1.146')
expect(post).toHaveBeenCalledWith(
'/admin/system/rollback',
{ version: '0.1.146' },
{ timeout: 15 * 60 * 1000 }
)
expect(result.need_restart).toBe(true)
})
it('rollback without a version posts no body (legacy backup rollback)', async () => {
post.mockResolvedValue({ data: { message: 'ok', need_restart: true } })
await rollback()
expect(post).toHaveBeenCalledWith(
'/admin/system/rollback',
undefined,
{ timeout: 15 * 60 * 1000 }
)
})
})
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: {
post,
},
}))
import {
batchUpdateLimits,
bindUserAuthIdentity,
type AdminBindAuthIdentityRequest,
type AdminBoundAuthIdentity,
type BatchUpdateUserLimitsRequest,
type BatchUpdateUserLimitsResponse,
} from '@/api/admin/users'
type Assert<T extends true> = T
type IsExact<T, U> = (
(<G>() => G extends T ? 1 : 2) extends (<G>() => G extends U ? 1 : 2)
? ((<G>() => G extends U ? 1 : 2) extends (<G>() => G extends T ? 1 : 2) ? true : false)
: false
)
type ExpectedAdminBindAuthIdentityRequest = {
provider_type: string
provider_key: string
provider_subject: string
issuer?: string
metadata?: Record<string, unknown>
channel?: {
channel: string
channel_app_id: string
channel_subject: string
metadata?: Record<string, unknown>
}
}
type ExpectedAdminBoundAuthIdentity = {
user_id: number
provider_type: string
provider_key: string
provider_subject: string
verified_at?: string | null
issuer?: string | null
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
channel?: {
channel: string
channel_app_id: string
channel_subject: string
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
} | null
}
const requestContractExact: Assert<
IsExact<AdminBindAuthIdentityRequest, ExpectedAdminBindAuthIdentityRequest>
> = true
const responseContractExact: Assert<
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
> = true
const batchRequestContractExact: Assert<
IsExact<
BatchUpdateUserLimitsRequest,
{
user_ids: number[]
all?: boolean
concurrency?: number
rpm_limit?: number
}
>
> = true
const batchResponseContractExact: Assert<
IsExact<BatchUpdateUserLimitsResponse, { affected: number }>
> = true
describe('admin users api auth identity binding', () => {
beforeEach(() => {
post.mockReset()
})
it('posts the backend-compatible auth identity bind payload and returns the backend response shape', async () => {
const payload: AdminBindAuthIdentityRequest = {
provider_type: 'wechat',
provider_key: 'wechat-main',
provider_subject: 'union-123',
metadata: { source: 'admin-repair' },
channel: {
channel: 'open',
channel_app_id: 'wx-open',
channel_subject: 'openid-123',
metadata: { scene: 'migration' },
},
}
const response: AdminBoundAuthIdentity = {
user_id: 9,
provider_type: 'wechat',
provider_key: 'wechat-main',
provider_subject: 'union-123',
verified_at: '2026-04-22T00:00:00Z',
issuer: null,
metadata: { source: 'admin-repair' },
created_at: '2026-04-22T00:00:00Z',
updated_at: '2026-04-22T00:00:00Z',
channel: {
channel: 'open',
channel_app_id: 'wx-open',
channel_subject: 'openid-123',
metadata: { scene: 'migration' },
created_at: '2026-04-22T00:00:00Z',
updated_at: '2026-04-22T00:00:00Z',
},
}
post.mockResolvedValue({ data: response })
const result = await bindUserAuthIdentity(9, payload)
expect(post).toHaveBeenCalledWith('/admin/users/9/auth-identities', payload)
expect(result).toEqual(response)
})
it('keeps bind auth identity request and response types aligned with the backend contract', () => {
expect(requestContractExact).toBe(true)
expect(responseContractExact).toBe(true)
})
it('posts batch limit updates once with only the supplied limit fields', async () => {
const request: BatchUpdateUserLimitsRequest = {
user_ids: [4, 7],
all: false,
rpm_limit: 0,
}
post.mockResolvedValue({ data: { affected: 2 } satisfies BatchUpdateUserLimitsResponse })
const result = await batchUpdateLimits(request)
expect(post).toHaveBeenCalledWith('/admin/users/batch-limits', request)
expect(result).toEqual({ affected: 2 })
expect(batchRequestContractExact).toBe(true)
expect(batchResponseContractExact).toBe(true)
})
})
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
ADMIN_UI_REQUEST_HEADER,
USER_UI_REQUEST_HEADER,
isUserTimingAPIPath,
shouldMarkAdminUIRequest,
shouldMarkUserUIRequest,
} from '@/api/adminUIRequest'
describe('Admin UI request marker', () => {
it('uses the stable request header name', () => {
expect(ADMIN_UI_REQUEST_HEADER).toBe('X-Admin-UI-Request')
})
it.each([
'/admin',
'/admin/users',
'/api/v1/admin',
'/api/v1/admin/accounts?status=active',
'https://api.example.test/api/v1/admin/dashboard',
])('marks Admin API request %s before page navigation', (requestURL) => {
expect(shouldMarkAdminUIRequest(requestURL, '/login')).toBe(true)
})
it.each(['/keys', '/groups/available', '/auth/me', '/announcements'])(
'marks shared request %s while an Admin page is active',
(requestURL) => {
expect(shouldMarkAdminUIRequest(requestURL, '/admin/dashboard')).toBe(true)
}
)
it.each([
['/keys', '/dashboard'],
['/api/v1/administer', '/dashboard'],
['/keys', '/administrator'],
['', '/'],
])('does not mark request %s on page %s', (requestURL, pagePath) => {
expect(shouldMarkAdminUIRequest(requestURL, pagePath)).toBe(false)
})
})
describe('User UI request marker', () => {
it('uses the stable request header name', () => {
expect(USER_UI_REQUEST_HEADER).toBe('X-User-UI-Request')
})
it.each([
'/auth/me',
'/auth/revoke-all-sessions',
'/auth/oauth/bind-token',
'/user',
'/user/profile',
'/user/password',
'/user/notify-email/send-code',
'/user/totp/status',
'/user/aff',
'/user/platform-quotas',
'/keys',
'/keys/12',
'/groups/available',
'/groups/rates',
'/channels/available',
'/usage',
'/usage/stats',
'/usage/dashboard/snapshot-v2',
'/announcements',
'/announcements/3/read',
'/redeem',
'/redeem/history',
'/subscriptions',
'/subscriptions/active',
'/channel-monitors',
'/channel-monitors/9/status',
'/payment/config',
'/payment/plans',
'/payment/orders',
'/payment/orders/my',
'/api/v1/auth/me',
'/api/v1/keys?page=1',
'https://api.example.test/api/v1/payment/orders/1',
])('marks user timing API %s', (requestURL) => {
expect(shouldMarkUserUIRequest(requestURL)).toBe(true)
expect(isUserTimingAPIPath(requestURL)).toBe(true)
})
it.each([
'/auth/login',
'/settings/public',
'/admin/users',
'/groups',
'/channels',
'/payment/public/orders/verify',
'/payment/webhook/stripe',
'/api/v1/payment/public/orders/resolve',
'',
])('does not mark non-user timing API %s', (requestURL) => {
expect(shouldMarkUserUIRequest(requestURL)).toBe(false)
})
})
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const post = vi.hoisted(() => vi.fn())
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import {
buildOAuthLoginStartURL,
startOAuthLogin,
type OAuthLoginStart
} from '@/api/auth'
describe('OAuth captcha start API', () => {
beforeEach(() => {
post.mockReset()
})
it('posts Tencent captcha proof and preserves OAuth query parameters', async () => {
const request: OAuthLoginStart = {
provider: 'github',
params: { redirect: '/dashboard', aff_code: 'AFF123' }
}
const proof = {
tencent_captcha_ticket: 'ticket',
tencent_captcha_randstr: '@rand'
}
post.mockResolvedValue({ data: { authorize_url: 'https://github.com/login/oauth/authorize' } })
await expect(startOAuthLogin(request, proof)).resolves.toEqual({
authorize_url: 'https://github.com/login/oauth/authorize'
})
expect(post).toHaveBeenCalledWith('/auth/oauth/github/start', proof, {
params: request.params
})
})
it('builds the legacy GET start URL when Tencent captcha is disabled', () => {
expect(buildOAuthLoginStartURL({
provider: 'wechat',
params: { mode: 'open', redirect: '/billing?plan=pro' }
})).toBe('/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro')
})
})
@@ -0,0 +1,224 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const post = vi.fn()
vi.mock('@/api/client', () => ({
apiClient: {
post
}
}))
describe('oauth adoption auth api', () => {
beforeEach(() => {
post.mockReset()
post.mockResolvedValue({ data: {} })
localStorage.clear()
document.cookie = 'oauth_bind_access_token=; Max-Age=0; path=/'
})
it('posts adoption decisions when exchanging pending oauth completion', async () => {
const { exchangePendingOAuthCompletion } = await import('@/api/auth')
await exchangePendingOAuthCompletion({
adoptDisplayName: false,
adoptAvatar: true
})
expect(post).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {
adopt_display_name: false,
adopt_avatar: true
})
})
it('posts bind-login decisions when finalizing pending oauth bind flow', async () => {
const { completePendingOAuthBindLogin } = await import('@/api/auth')
await completePendingOAuthBindLogin({
adoptDisplayName: true,
adoptAvatar: false
})
expect(post).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {
adopt_display_name: true,
adopt_avatar: false
})
})
it('posts linuxdo invitation completion with adoption decisions', async () => {
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
await completeLinuxDoOAuthRegistration('invite-code', {
adoptDisplayName: true,
adoptAvatar: false
})
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: true,
adopt_avatar: false
})
})
it('posts linuxdo create-account completion with adoption decisions', async () => {
const { createPendingLinuxDoOAuthAccount } = await import('@/api/auth')
await createPendingLinuxDoOAuthAccount('invite-code', {
adoptDisplayName: false,
adoptAvatar: true
})
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: false,
adopt_avatar: true
})
})
it('posts affiliate code when completing linuxdo oauth registration', async () => {
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
await completeLinuxDoOAuthRegistration(
'invite-code',
{
adoptDisplayName: true,
adoptAvatar: false
},
' AFF123 '
)
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
invitation_code: 'invite-code',
aff_code: 'AFF123',
adopt_display_name: true,
adopt_avatar: false
})
})
it('posts oidc invitation completion with adoption decisions', async () => {
const { completeOIDCOAuthRegistration } = await import('@/api/auth')
await completeOIDCOAuthRegistration('invite-code', {
adoptDisplayName: false,
adoptAvatar: true
})
expect(post).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: false,
adopt_avatar: true
})
})
it('posts oidc create-account completion with adoption decisions', async () => {
const { createPendingOIDCOAuthAccount } = await import('@/api/auth')
await createPendingOIDCOAuthAccount('invite-code', {
adoptDisplayName: true,
adoptAvatar: false
})
expect(post).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: true,
adopt_avatar: false
})
})
it('posts wechat invitation completion with adoption decisions', async () => {
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
await completeWeChatOAuthRegistration('invite-code', {
adoptDisplayName: true,
adoptAvatar: true
})
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: true,
adopt_avatar: true
})
})
it('posts wechat create-account completion with adoption decisions', async () => {
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
await createPendingWeChatOAuthAccount('invite-code', {
adoptDisplayName: false,
adoptAvatar: false
})
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: false,
adopt_avatar: false
})
})
it('posts affiliate code when creating pending wechat oauth account', async () => {
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
await createPendingWeChatOAuthAccount(
'invite-code',
{
adoptDisplayName: false,
adoptAvatar: true
},
'WXAFF'
)
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
invitation_code: 'invite-code',
aff_code: 'WXAFF',
adopt_display_name: false,
adopt_avatar: true
})
})
it('classifies oauth completion results as login or bind', async () => {
const { getOAuthCompletionKind } = await import('@/api/auth')
expect(getOAuthCompletionKind({ access_token: 'access-token' })).toBe('login')
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
})
it('provides bind-login utility helpers for invitation and suggested profile states', async () => {
const {
getPendingOAuthBindLoginKind,
hasPendingOAuthSuggestedProfile,
isPendingOAuthCreateAccountRequired
} = await import('@/api/auth')
expect(getPendingOAuthBindLoginKind({ access_token: 'access-token' })).toBe('login')
expect(getPendingOAuthBindLoginKind({ redirect: '/profile' })).toBe('bind')
expect(
isPendingOAuthCreateAccountRequired({
error: 'invitation_required'
})
).toBe(true)
expect(
isPendingOAuthCreateAccountRequired({
error: 'other'
})
).toBe(false)
expect(
hasPendingOAuthSuggestedProfile({
suggested_display_name: 'OAuth Nick'
})
).toBe(true)
expect(
hasPendingOAuthSuggestedProfile({
suggested_avatar_url: 'https://cdn.example/avatar.png'
})
).toBe(true)
expect(hasPendingOAuthSuggestedProfile({})).toBe(false)
})
it('requests an HttpOnly oauth bind cookie before redirect binding', async () => {
localStorage.setItem('auth_token', 'access-token-value')
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
await prepareOAuthBindAccessTokenCookie()
expect(post).toHaveBeenCalledWith('/auth/oauth/bind-token')
})
})
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apiClient } from '../client'
import { getMatrix, repeatedArrayParamsSerializer } from '../channelMonitorV2'
afterEach(() => vi.restoreAllMocks())
describe('channel monitor V2 query serialization', () => {
it('uses repeated keys without bracket suffixes for array filters', () => {
const query = repeatedArrayParamsSerializer({
range: '90m',
platform: ['openai', 'grok'],
group_id: [1, 2],
model: undefined,
group_by: 'platform_group_model',
})
expect(query).toBe('range=90m&platform=openai&platform=grok&group_id=1&group_id=2&group_by=platform_group_model')
expect(query).not.toContain('%5B%5D')
})
it('sends the matrix grouping with the shared filters', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: { coverage: {}, group_by: 'platform_group', items: [] },
})
await getMatrix({ range: '24h', platforms: ['openai'], groupIds: [7], models: [] }, 'platform_group', true)
expect(get).toHaveBeenCalledWith('/admin/channel-monitor-v2/matrix', expect.objectContaining({
params: {
range: '24h',
platform: ['openai'],
group_id: [7],
model: undefined,
group_by: 'platform_group',
},
}))
})
})
+474
View File
@@ -0,0 +1,474 @@
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()
window.history.replaceState({}, '', '/')
// 每次测试重新导入以获取干净的模块状态
vi.resetModules()
const mod = await import('@/api/client')
apiClient = mod.apiClient
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
// --- 请求拦截器 ---
describe('请求拦截器', () => {
it('规范化相对 API base,避免在回调页拼出相对 v1 路径', async () => {
vi.resetModules()
vi.stubEnv('VITE_API_BASE_URL', 'api/v1')
const mod = await import('@/api/client')
expect(mod.apiClient.defaults.baseURL).toBe('/api/v1')
expect(mod.buildApiUrl('/auth/oauth/github/callback?code=abc')).toBe(
'/api/v1/auth/oauth/github/callback?code=abc'
)
})
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()
})
it('请求默认带 withCredentials 以支持跨域 cookie', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.post('/auth/oauth/bind-token')
const config = adapter.mock.calls[0][0]
expect(config.withCredentials).toBe(true)
})
it('Admin API 在进入管理页面前也带 Admin UI 标记', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/admin/users')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
})
it('管理页面调用共享 API 时带 Admin UI 标记', async () => {
window.history.replaceState({}, '', '/admin/dashboard')
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/groups/available')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
})
it('普通用户页面调用共享 API 时不带 Admin UI 标记', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/groups/available')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
})
it('用户侧 timing API 自动带 User UI 标记', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/auth/me')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-User-UI-Request')).toBe('1')
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
})
it('支付用户 API 带 User UI 标记,公开支付 API 不带', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/payment/plans')
expect(adapter.mock.calls[0][0].headers.get('X-User-UI-Request')).toBe('1')
await apiClient.post('/payment/public/orders/verify', {})
expect(adapter.mock.calls[1][0].headers.get('X-User-UI-Request')).toBeFalsy()
})
it('管理页调用共享 API 时同时带 Admin 与 User UI 标记', async () => {
window.history.replaceState({}, '', '/admin/dashboard')
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/keys')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
expect(config.headers.get('X-User-UI-Request')).toBe('1')
})
})
// --- 响应拦截器 ---
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: '参数错误',
})
)
})
it('部署与运营合规未确认时广播事件且保留登录态', async () => {
localStorage.setItem('auth_token', 'admin-token')
const listener = vi.fn()
window.addEventListener('admin-compliance-required', listener)
const adapter = vi.fn().mockRejectedValue({
response: {
status: 423,
data: {
code: 'ADMIN_COMPLIANCE_ACK_REQUIRED',
message: 'administrator compliance acknowledgement is required',
metadata: {
version: 'v2026.06.10',
document_path_zh: 'docs/legal/admin-compliance.zh.md',
document_path_en: 'docs/legal/admin-compliance.en.md',
},
},
},
config: {
url: '/admin/users',
headers: { Authorization: 'Bearer admin-token' },
},
code: 'ERR_BAD_REQUEST',
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/admin/users')).rejects.toEqual(
expect.objectContaining({
status: 423,
code: 'ADMIN_COMPLIANCE_ACK_REQUIRED',
metadata: expect.objectContaining({
version: 'v2026.06.10',
}),
})
)
expect(listener).toHaveBeenCalledTimes(1)
expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual(
expect.objectContaining({
version: 'v2026.06.10',
})
)
expect(localStorage.getItem('auth_token')).toBe('admin-token')
window.removeEventListener('admin-compliance-required', listener)
})
})
// --- 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,
})
})
it('有 refresh_token 时刷新并重试原请求', async () => {
localStorage.setItem('auth_token', 'expired-token')
localStorage.setItem('refresh_token', 'refresh-token')
localStorage.setItem('token_expires_at', String(Date.now() - 1))
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
const adapter = vi.fn()
.mockRejectedValueOnce({
response: {
status: 401,
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
},
config: {
url: '/test',
headers: { Authorization: 'Bearer expired-token' },
},
code: 'ERR_BAD_REQUEST',
})
.mockResolvedValueOnce({
status: 200,
data: { code: 0, data: { ok: true } },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
vi.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
code: 0,
message: 'ok',
data: {
access_token: 'new-token',
refresh_token: 'new-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
},
},
})
await expect(apiClient.get('/test')).resolves.toMatchObject({ data: { ok: true } })
expect(adapter).toHaveBeenCalledTimes(2)
expect(localStorage.getItem('auth_token')).toBe('new-token')
expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token')
expect(adapter.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer new-token')
})
it('刷新期间换号时旧请求不会清除新会话', async () => {
localStorage.setItem('auth_token', 'user-a-access')
localStorage.setItem('refresh_token', 'user-a-refresh')
localStorage.setItem('token_expires_at', String(Date.now() - 1))
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
apiClient.defaults.adapter = vi.fn().mockRejectedValueOnce({
response: {
status: 401,
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
},
config: {
url: '/test',
headers: { Authorization: 'Bearer user-a-access' },
},
code: 'ERR_BAD_REQUEST',
})
let rejectRefresh!: (reason: Error) => void
vi.spyOn(axios, 'post').mockImplementationOnce(
() => new Promise((_resolve, reject) => {
rejectRefresh = reject
})
)
const staleRequest = apiClient.get('/test')
await vi.waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1))
localStorage.setItem('auth_token', 'user-b-access')
localStorage.setItem('refresh_token', 'user-b-refresh')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
rejectRefresh(new Error('stale refresh failed'))
await expect(staleRequest).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' })
expect(localStorage.getItem('auth_token')).toBe('user-b-access')
expect(localStorage.getItem('refresh_token')).toBe('user-b-refresh')
expect(localStorage.getItem('auth_user')).toBe(JSON.stringify({ id: 8 }))
expect(window.location.pathname).toBe('/')
})
})
// --- 网络错误 ---
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()
})
})
})
+200
View File
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
remove: vi.fn(),
credentialGet: vi.fn(),
credentialCreate: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: {
get,
post,
patch,
delete: remove
}
}))
import { passkeyAPI } from '@/api/passkey'
class FakePublicKeyCredential {
id = 'credential-id'
rawId = Uint8Array.from([1, 2, 3]).buffer
type = 'public-key'
authenticatorAttachment = 'platform'
response: Record<string, unknown> = {
authenticatorData: Uint8Array.from([4, 5]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
signature: Uint8Array.from([8, 9]).buffer,
userHandle: Uint8Array.from([10, 11]).buffer
}
getClientExtensionResults(): AuthenticationExtensionsClientOutputs {
return {}
}
}
class FakeRegistrationCredential extends FakePublicKeyCredential {
constructor() {
super()
this.response = {
attestationObject: Uint8Array.from([12, 13]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
getTransports: () => ['internal']
}
}
}
describe('passkey api', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
patch.mockReset()
remove.mockReset()
credentialGet.mockReset()
credentialCreate.mockReset()
vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential)
Object.defineProperty(window, 'PublicKeyCredential', {
configurable: true,
value: FakePublicKeyCredential
})
Object.defineProperty(navigator, 'credentials', {
configurable: true,
value: { get: credentialGet, create: credentialCreate }
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('converts assertion options and response bytes to WebAuthn JSON', async () => {
post
.mockResolvedValueOnce({
data: {
session_token: 'one-time-session',
options: {
publicKey: {
challenge: 'AQID',
rpId: 'sub2api.example.com',
userVerification: 'required'
}
}
}
})
.mockResolvedValueOnce({
data: {
access_token: 'access',
token_type: 'Bearer',
user: { id: 1 }
}
})
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
await passkeyAPI.login()
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin')
const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions
expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3])
expect(request.publicKey!.userVerification).toBe('required')
expect(post).toHaveBeenNthCalledWith(2, '/auth/passkey/login/finish', {
session_token: 'one-time-session',
credential: {
id: 'credential-id',
rawId: 'AQID',
type: 'public-key',
authenticatorAttachment: 'platform',
clientExtensionResults: {},
response: {
authenticatorData: 'BAU',
clientDataJSON: 'Bgc',
signature: 'CAk',
userHandle: 'Cgs'
}
}
})
})
it('sends Tencent captcha proof only with the passkey begin request', async () => {
post
.mockResolvedValueOnce({
data: {
session_token: 'one-time-session',
options: {
publicKey: {
challenge: 'AQID',
rpId: 'sub2api.example.com',
userVerification: 'required'
}
}
}
})
.mockResolvedValueOnce({ data: { access_token: 'access', token_type: 'Bearer', user: { id: 1 } } })
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
await passkeyAPI.login({
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin', {
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(post.mock.calls[1][1]).not.toEqual(expect.objectContaining({
tencent_captcha_ticket: expect.anything(),
tencent_captcha_randstr: expect.anything()
}))
})
it('sends the account password when beginning registration', async () => {
post
.mockResolvedValueOnce({
data: {
session_token: 'register-session',
options: {
publicKey: {
challenge: 'AQID',
user: { id: 'BAU', name: 'user@example.com', displayName: 'user' }
}
}
}
})
.mockResolvedValueOnce({
data: { id: 3, name: 'Laptop', created_at: '2026-07-28T00:00:00Z', backup: false }
})
credentialCreate.mockResolvedValue(new FakeRegistrationCredential())
await passkeyAPI.register('Laptop', 'hunter2')
expect(post).toHaveBeenNthCalledWith(1, '/user/passkeys/register/begin', {
password: 'hunter2'
})
expect(post).toHaveBeenNthCalledWith(2, '/user/passkeys/register/finish', {
session_token: 'register-session',
name: 'Laptop',
credential: expect.objectContaining({
response: {
attestationObject: 'DA0',
clientDataJSON: 'Bgc',
transports: ['internal']
}
})
})
})
it('sends the account password when revoking a credential', async () => {
remove.mockResolvedValue({ data: null })
await passkeyAPI.remove(12, 'hunter2')
expect(remove).toHaveBeenCalledWith('/user/passkeys/12', {
data: { password: 'hunter2' }
})
})
})
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: {
get,
post,
},
}))
import { paymentAPI } from '@/api/payment'
describe('payment api', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
get.mockResolvedValue({ data: {} })
post.mockResolvedValue({ data: {} })
})
it('keeps legacy public out_trade_no verification for upgrade compatibility', async () => {
await paymentAPI.verifyOrderPublic('legacy-order-no')
expect(post).toHaveBeenCalledWith('/payment/public/orders/verify', {
out_trade_no: 'legacy-order-no',
})
})
it('keeps signed public resume-token resolve endpoint', async () => {
await paymentAPI.resolveOrderPublicByResumeToken('resume-token-123')
expect(post).toHaveBeenCalledWith('/payment/public/orders/resolve', {
resume_token: 'resume-token-123',
})
})
})
@@ -0,0 +1,298 @@
import { describe, expect, it } from "vitest";
import {
appendAuthSourceDefaultsToUpdateRequest,
buildAuthSourceDefaultsState,
normalizePlatformQuotasMap,
sanitizePlatformQuotasMap,
type UpdateSettingsRequest,
type DefaultPlatformQuotasMap,
} from "@/api/admin/settings";
/** 全 null 的 5 平台 map,用于断言归一化默认值 */
const allNullQuotas: DefaultPlatformQuotasMap = {
anthropic: { daily: null, weekly: null, monthly: null },
openai: { daily: null, weekly: null, monthly: null },
gemini: { daily: null, weekly: null, monthly: null },
antigravity: { daily: null, weekly: null, monthly: null },
grok: { daily: null, weekly: null, monthly: null },
}
describe("admin settings auth source defaults helpers", () => {
it("builds auth source defaults state from flat settings fields", () => {
const state = buildAuthSourceDefaultsState({
auth_source_default_email_balance: 9.5,
auth_source_default_email_concurrency: 3,
auth_source_default_email_subscriptions: [
{ group_id: 1, validity_days: 30 },
],
auth_source_default_email_grant_on_signup: false,
auth_source_default_email_grant_on_first_bind: true,
auth_source_default_linuxdo_balance: 6,
auth_source_default_linuxdo_concurrency: 8,
auth_source_default_linuxdo_subscriptions: [
{ group_id: 2, validity_days: 60 },
],
auth_source_default_linuxdo_grant_on_signup: true,
auth_source_default_linuxdo_grant_on_first_bind: false,
});
expect(state.email).toEqual({
balance: 9.5,
concurrency: 3,
subscriptions: [{ group_id: 1, validity_days: 30 }],
grant_on_signup: false,
grant_on_first_bind: true,
platform_quotas: allNullQuotas,
});
expect(state.linuxdo).toEqual({
balance: 6,
concurrency: 8,
subscriptions: [{ group_id: 2, validity_days: 60 }],
grant_on_signup: true,
grant_on_first_bind: false,
platform_quotas: allNullQuotas,
});
expect(state.oidc).toEqual({
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: allNullQuotas,
});
expect(state.wechat).toEqual({
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: allNullQuotas,
});
});
it("defaults grant-on-signup to disabled when settings are missing", () => {
const state = buildAuthSourceDefaultsState({});
expect(state.email.grant_on_signup).toBe(false);
expect(state.linuxdo.grant_on_signup).toBe(false);
expect(state.oidc.grant_on_signup).toBe(false);
expect(state.wechat.grant_on_signup).toBe(false);
});
it("reads nested platform_quotas from settings into auth source state", () => {
const state = buildAuthSourceDefaultsState({
auth_source_default_email_platform_quotas: {
anthropic: { daily: 10, weekly: 50, monthly: 200 },
openai: { daily: null, weekly: null, monthly: null },
} as DefaultPlatformQuotasMap,
});
// anthropic 填写的值应被保留
expect(state.email.platform_quotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
// openai 全 null 应被保留
expect(state.email.platform_quotas.openai).toEqual({ daily: null, weekly: null, monthly: null });
// 未出现的平台(gemini/antigravity)归一化为 null
expect(state.email.platform_quotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
expect(state.email.platform_quotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
});
it("appends auth source defaults back onto update payload", () => {
const payload: UpdateSettingsRequest = {
site_name: "Sub2API",
};
appendAuthSourceDefaultsToUpdateRequest(payload, {
email: {
balance: 1.25,
concurrency: 2,
subscriptions: [{ group_id: 3, validity_days: 7 }],
grant_on_signup: true,
grant_on_first_bind: false,
platform_quotas: {},
},
linuxdo: {
balance: 0,
concurrency: 6,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: true,
platform_quotas: {},
},
oidc: {
balance: 4,
concurrency: 9,
subscriptions: [{ group_id: 9, validity_days: 90 }],
grant_on_signup: true,
grant_on_first_bind: true,
platform_quotas: {},
},
wechat: {
balance: 2,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
github: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
google: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
dingtalk: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
});
expect(payload).toMatchObject({
site_name: "Sub2API",
auth_source_default_email_balance: 1.25,
auth_source_default_email_concurrency: 2,
auth_source_default_email_subscriptions: [
{ group_id: 3, validity_days: 7 },
],
auth_source_default_email_grant_on_signup: true,
auth_source_default_email_grant_on_first_bind: false,
auth_source_default_linuxdo_balance: 0,
auth_source_default_linuxdo_concurrency: 6,
auth_source_default_linuxdo_subscriptions: [],
auth_source_default_linuxdo_grant_on_signup: false,
auth_source_default_linuxdo_grant_on_first_bind: true,
auth_source_default_oidc_balance: 4,
auth_source_default_oidc_concurrency: 9,
auth_source_default_oidc_subscriptions: [
{ group_id: 9, validity_days: 90 },
],
auth_source_default_oidc_grant_on_signup: true,
auth_source_default_oidc_grant_on_first_bind: true,
auth_source_default_wechat_balance: 2,
auth_source_default_wechat_concurrency: 5,
auth_source_default_wechat_subscriptions: [],
auth_source_default_wechat_grant_on_signup: false,
auth_source_default_wechat_grant_on_first_bind: false,
// 嵌套 platform_quotas 字段
auth_source_default_email_platform_quotas: allNullQuotas,
auth_source_default_linuxdo_platform_quotas: allNullQuotas,
auth_source_default_oidc_platform_quotas: allNullQuotas,
auth_source_default_wechat_platform_quotas: allNullQuotas,
auth_source_default_github_platform_quotas: allNullQuotas,
auth_source_default_google_platform_quotas: allNullQuotas,
auth_source_default_dingtalk_platform_quotas: allNullQuotas,
});
});
it("appends sanitized nested platform_quotas with non-null values in update payload", () => {
const payload: UpdateSettingsRequest = {};
appendAuthSourceDefaultsToUpdateRequest(payload, {
email: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {
anthropic: { daily: 10, weekly: 50, monthly: 200 },
openai: { daily: 0, weekly: null, monthly: null },
},
},
linuxdo: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
oidc: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
wechat: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
github: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
google: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
dingtalk: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
});
const emailQuotas = (payload as Record<string, unknown>)["auth_source_default_email_platform_quotas"] as DefaultPlatformQuotasMap;
expect(emailQuotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
// 0 是合法值(不限额=0 与"不设"不同,保留)
expect(emailQuotas.openai?.daily).toBe(0);
// 缺失平台归一化为全 null
expect(emailQuotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
expect(emailQuotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
});
});
describe("normalizePlatformQuotasMap", () => {
it("填充缺失的平台为全 null 三档", () => {
const result = normalizePlatformQuotasMap({ anthropic: { daily: 5, weekly: null, monthly: null } });
expect(result.anthropic).toEqual({ daily: 5, weekly: null, monthly: null });
expect(result.openai).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.gemini).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.grok).toEqual({ daily: null, weekly: null, monthly: null });
});
it("无参数时返回全 5 平台全 null", () => {
const result = normalizePlatformQuotasMap();
expect(Object.keys(result)).toHaveLength(5);
for (const v of Object.values(result)) {
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
}
});
it("非 number 类型的值归一化为 null", () => {
const result = normalizePlatformQuotasMap({
anthropic: { daily: "50" as unknown as number, weekly: undefined as unknown as number, monthly: null },
});
expect(result.anthropic).toEqual({ daily: null, weekly: null, monthly: null });
});
});
describe("sanitizePlatformQuotasMap", () => {
it("保留合法的正数和零值", () => {
const result = sanitizePlatformQuotasMap({
anthropic: { daily: 10.5, weekly: 0, monthly: null },
});
expect(result.anthropic?.daily).toBe(10.5);
expect(result.anthropic?.weekly).toBe(0);
expect(result.anthropic?.monthly).toBe(null);
});
it("空字符串(v-model.number 空输入)清洗为 null", () => {
const result = sanitizePlatformQuotasMap({
anthropic: { daily: "" as unknown as number, weekly: null, monthly: null },
});
expect(result.anthropic?.daily).toBe(null);
});
it("负数清洗为 null", () => {
const result = sanitizePlatformQuotasMap({
openai: { daily: -1, weekly: null, monthly: null },
});
expect(result.openai?.daily).toBe(null);
});
it("NaN/Infinity 清洗为 null", () => {
const result = sanitizePlatformQuotasMap({
gemini: { daily: NaN, weekly: Infinity, monthly: null },
});
expect(result.gemini?.daily).toBe(null);
expect(result.gemini?.weekly).toBe(null);
});
it("缺失平台填充为全 null", () => {
const result = sanitizePlatformQuotasMap({});
expect(Object.keys(result)).toHaveLength(5);
for (const v of Object.values(result)) {
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
}
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
getPaymentVisibleMethodSourceOptions,
normalizePaymentVisibleMethodSource,
} from '@/api/admin/settings'
describe('admin settings payment visible method helpers', () => {
it('normalizes aliases into canonical source keys per visible method', () => {
expect(normalizePaymentVisibleMethodSource('alipay', 'official')).toBe('official_alipay')
expect(normalizePaymentVisibleMethodSource('alipay', 'alipay_direct')).toBe('official_alipay')
expect(normalizePaymentVisibleMethodSource('alipay', 'easypay')).toBe('easypay_alipay')
expect(normalizePaymentVisibleMethodSource('wxpay', 'official')).toBe('official_wxpay')
expect(normalizePaymentVisibleMethodSource('wxpay', 'wechat')).toBe('official_wxpay')
expect(normalizePaymentVisibleMethodSource('wxpay', 'easypay')).toBe('easypay_wxpay')
})
it('rejects unknown or cross-method source values', () => {
expect(normalizePaymentVisibleMethodSource('alipay', 'official_wxpay')).toBe('')
expect(normalizePaymentVisibleMethodSource('wxpay', 'official_alipay')).toBe('')
expect(normalizePaymentVisibleMethodSource('alipay', 'unknown')).toBe('')
expect(normalizePaymentVisibleMethodSource('wxpay', null)).toBe('')
})
it('exposes method-scoped source options instead of arbitrary strings', () => {
expect(getPaymentVisibleMethodSourceOptions('alipay')).toEqual([
{
value: '',
labelZh: '未配置',
labelEn: 'Not configured',
},
{
value: 'official_alipay',
labelZh: '支付宝官方',
labelEn: 'Official Alipay',
},
{
value: 'easypay_alipay',
labelZh: '易支付支付宝',
labelEn: 'EasyPay Alipay',
},
])
expect(getPaymentVisibleMethodSourceOptions('wxpay')).toEqual([
{
value: '',
labelZh: '未配置',
labelEn: 'Not configured',
},
{
value: 'official_wxpay',
labelZh: '微信官方',
labelEn: 'Official WeChat Pay',
},
{
value: 'easypay_wxpay',
labelZh: '易支付微信',
labelEn: 'EasyPay WeChat Pay',
},
])
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
defaultWeChatConnectScopesForMode,
normalizeWeChatConnectMode,
} from "@/api/admin/settings";
describe("admin settings wechat connect helpers", () => {
it("normalizes legacy or noisy mode values to the backend contract", () => {
expect(normalizeWeChatConnectMode("OPEN")).toBe("open");
expect(normalizeWeChatConnectMode(" open_platform ")).toBe("open");
expect(normalizeWeChatConnectMode("mp")).toBe("mp");
expect(normalizeWeChatConnectMode("official_account")).toBe("mp");
expect(normalizeWeChatConnectMode("unknown")).toBe("open");
});
it("maps each mode to the backend default scopes", () => {
expect(defaultWeChatConnectScopesForMode("open")).toBe("snsapi_login");
expect(defaultWeChatConnectScopesForMode("mp")).toBe("snsapi_userinfo");
});
});
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import axios from 'axios'
vi.mock('axios', () => ({
default: {
post: vi.fn()
}
}))
const mockedPost = vi.mocked(axios.post)
function seedSession(overrides: Partial<Record<string, string>> = {}): void {
localStorage.setItem('auth_token', overrides.auth_token || 'old-access')
localStorage.setItem('refresh_token', overrides.refresh_token || 'old-refresh')
localStorage.setItem('token_expires_at', overrides.token_expires_at || String(Date.now() - 1))
localStorage.setItem('auth_user', JSON.stringify({ id: 7, email: 'admin@example.com' }))
}
function refreshedResponse() {
return {
data: {
code: 0,
message: 'ok',
data: {
access_token: 'new-access',
refresh_token: 'new-refresh',
expires_in: 3600,
token_type: 'Bearer'
}
}
}
}
describe('refreshAuthTokens', () => {
beforeEach(() => {
localStorage.clear()
mockedPost.mockReset()
vi.resetModules()
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: undefined
})
})
afterEach(() => {
vi.useRealTimers()
})
it('shares one refresh request between concurrent callers in the same document', async () => {
seedSession()
let resolveRequest!: (value: ReturnType<typeof refreshedResponse>) => void
mockedPost.mockImplementationOnce(
() => new Promise((resolve) => {
resolveRequest = resolve
})
)
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
const first = refreshAuthTokens({ failedAccessToken: 'old-access' })
const second = refreshAuthTokens({ failedAccessToken: 'old-access' })
expect(mockedPost).toHaveBeenCalledTimes(1)
resolveRequest(refreshedResponse())
await expect(first).resolves.toMatchObject({ access_token: 'new-access' })
await expect(second).resolves.toMatchObject({ refresh_token: 'new-refresh' })
expect(localStorage.getItem('refresh_token')).toBe('new-refresh')
})
it('adopts tokens refreshed by another tab after acquiring the Web Lock', async () => {
seedSession()
const request = vi.fn(async (_name: string, callback: () => Promise<unknown>) => {
localStorage.setItem('auth_token', 'peer-access')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('refresh_token', 'peer-refresh')
return callback()
})
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request }
})
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
const result = await refreshAuthTokens({ failedAccessToken: 'old-access' })
expect(request).toHaveBeenCalledTimes(1)
expect(mockedPost).not.toHaveBeenCalled()
expect(result).toMatchObject({
access_token: 'peer-access',
refresh_token: 'peer-refresh'
})
})
it('recovers when a peer publishes the rotated token just after this request fails', async () => {
seedSession()
mockedPost.mockRejectedValueOnce(new Error('refresh token already used'))
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
window.setTimeout(() => {
localStorage.setItem('auth_token', 'peer-access')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('refresh_token', 'peer-refresh')
}, 10)
await expect(
refreshAuthTokens({ failedAccessToken: 'old-access' })
).resolves.toMatchObject({
access_token: 'peer-access',
refresh_token: 'peer-refresh'
})
})
it('waits for a slow peer after losing a refresh-token race without Web Locks', async () => {
vi.useFakeTimers()
seedSession()
let resolveWinningRequest!: (value: ReturnType<typeof refreshedResponse>) => void
mockedPost.mockImplementationOnce(
() => new Promise((resolve) => {
resolveWinningRequest = resolve
})
)
const firstTab = await import('@/api/tokenRefresh')
vi.resetModules()
const secondTab = await import('@/api/tokenRefresh')
const winner = firstTab.refreshAuthTokens({ failedAccessToken: 'old-access' })
mockedPost.mockRejectedValueOnce({ response: { status: 401 } })
const loser = secondTab.refreshAuthTokens({ failedAccessToken: 'old-access' })
window.setTimeout(() => resolveWinningRequest(refreshedResponse()), 1_500)
await vi.advanceTimersByTimeAsync(1_600)
await expect(winner).resolves.toMatchObject({ access_token: 'new-access' })
await expect(loser).resolves.toMatchObject({ refresh_token: 'new-refresh' })
expect(mockedPost).toHaveBeenCalledTimes(2)
expect(localStorage.getItem('refresh_token')).toBe('new-refresh')
})
it('does not adopt a token from a different signed-in user', async () => {
vi.useFakeTimers()
seedSession()
mockedPost.mockRejectedValueOnce(new Error('refresh token already used'))
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
window.setTimeout(() => {
localStorage.setItem('auth_user', JSON.stringify({ id: 8, email: 'other@example.com' }))
localStorage.setItem('auth_token', 'other-access')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('refresh_token', 'other-refresh')
}, 10)
const rejection = expect(
refreshAuthTokens({ failedAccessToken: 'old-access' })
).rejects.toThrow('refresh token already used')
await vi.advanceTimersByTimeAsync(1_100)
await rejection
})
it('does not restore a session that was logged out while refresh was in flight', async () => {
vi.useFakeTimers()
seedSession()
let resolveRequest!: (value: ReturnType<typeof refreshedResponse>) => void
mockedPost.mockImplementationOnce(
() => new Promise((resolve) => {
resolveRequest = resolve
})
)
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
const pending = refreshAuthTokens({ failedAccessToken: 'old-access' })
localStorage.clear()
resolveRequest(refreshedResponse())
const rejection = expect(pending).rejects.toThrow('Session changed during token refresh')
await vi.advanceTimersByTimeAsync(1_100)
await rejection
expect(localStorage.getItem('auth_token')).toBeNull()
expect(localStorage.getItem('refresh_token')).toBeNull()
})
})
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('user api oauth binding urls', () => {
beforeEach(() => {
vi.resetModules()
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/api/v1')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('builds third-party bind urls against the bind start endpoint', async () => {
const { buildOAuthBindingStartURL } = await import('@/api/user')
expect(buildOAuthBindingStartURL('linuxdo', { redirectTo: '/settings/profile' })).toBe(
'https://api.example.com/api/v1/auth/oauth/linuxdo/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user'
)
expect(
buildOAuthBindingStartURL('wechat', {
redirectTo: '/settings/profile',
wechatOAuthSettings: {
wechat_oauth_open_enabled: true,
wechat_oauth_mp_enabled: false,
wechat_oauth_mobile_enabled: false
}
})
).toBe(
'https://api.example.com/api/v1/auth/oauth/wechat/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user&mode=open'
)
})
})
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
/**
* Admin Affiliate API endpoints
* Manage per-user affiliate (邀请返利) configurations:
* exclusive invite codes (overrides aff_code) and exclusive rebate rates.
*/
import { apiClient } from '../client'
import type { PaginatedResponse } from '@/types'
export interface AffiliateAdminEntry {
user_id: number
email: string
username: string
aff_code: string
aff_code_custom: boolean
aff_rebate_rate_percent?: number | null
aff_count: number
}
export interface ListAffiliateUsersParams {
page?: number
page_size?: number
search?: string
}
export interface ListAffiliateRecordsParams {
page?: number
page_size?: number
search?: string
start_at?: string
end_at?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
timezone?: string
}
export interface AffiliateInviteRecord {
inviter_id: number
inviter_email: string
inviter_username: string
invitee_id: number
invitee_email: string
invitee_username: string
aff_code: string
total_rebate: number
created_at: string
}
export interface AffiliateRebateRecord {
order_id: number
out_trade_no: string
inviter_id: number
inviter_email: string
inviter_username: string
invitee_id: number
invitee_email: string
invitee_username: string
order_amount: number
pay_amount: number
rebate_amount: number
payment_type: string
order_status: string
created_at: string
}
export interface AffiliateTransferRecord {
ledger_id: number
user_id: number
user_email: string
username: string
amount: number
balance_after?: number | null
available_quota_after?: number | null
frozen_quota_after?: number | null
history_quota_after?: number | null
snapshot_available: boolean
created_at: string
}
export interface AffiliateUserOverview {
user_id: number
email: string
username: string
aff_code: string
rebate_rate_percent: number
invited_count: number
rebated_invitee_count: number
available_quota: number
history_quota: number
}
export interface UpdateAffiliateUserRequest {
aff_code?: string
aff_rebate_rate_percent?: number | null
/** Set true to explicitly clear the per-user rate (sets it to NULL). */
clear_rebate_rate?: boolean
}
export interface BatchSetRateRequest {
user_ids: number[]
aff_rebate_rate_percent?: number | null
/** Set true to clear rates instead of setting. */
clear?: boolean
}
export interface SimpleUser {
id: number
email: string
username: string
}
export async function listUsers(
params: ListAffiliateUsersParams = {},
): Promise<PaginatedResponse<AffiliateAdminEntry>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateAdminEntry>>(
'/admin/affiliates/users',
{
params: {
page: params.page ?? 1,
page_size: params.page_size ?? 20,
search: params.search ?? '',
},
},
)
return data
}
export async function lookupUsers(q: string): Promise<SimpleUser[]> {
const { data } = await apiClient.get<SimpleUser[]>(
'/admin/affiliates/users/lookup',
{ params: { q } },
)
return data
}
export async function updateUserSettings(
userId: number,
payload: UpdateAffiliateUserRequest,
): Promise<{ user_id: number }> {
const { data } = await apiClient.put<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
payload,
)
return data
}
export async function clearUserSettings(
userId: number,
): Promise<{ user_id: number }> {
const { data } = await apiClient.delete<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
)
return data
}
export async function batchSetRate(
payload: BatchSetRateRequest,
): Promise<{ affected: number }> {
const { data } = await apiClient.post<{ affected: number }>(
'/admin/affiliates/users/batch-rate',
payload,
)
return data
}
function recordParams(params: ListAffiliateRecordsParams = {}) {
return {
page: params.page ?? 1,
page_size: params.page_size ?? 20,
search: params.search ?? '',
start_at: params.start_at || undefined,
end_at: params.end_at || undefined,
sort_by: params.sort_by || undefined,
sort_order: params.sort_order || undefined,
timezone: params.timezone || undefined,
}
}
export async function listInviteRecords(
params: ListAffiliateRecordsParams = {},
): Promise<PaginatedResponse<AffiliateInviteRecord>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateInviteRecord>>(
'/admin/affiliates/invites',
{ params: recordParams(params) },
)
return data
}
export async function listRebateRecords(
params: ListAffiliateRecordsParams = {},
): Promise<PaginatedResponse<AffiliateRebateRecord>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateRebateRecord>>(
'/admin/affiliates/rebates',
{ params: recordParams(params) },
)
return data
}
export async function listTransferRecords(
params: ListAffiliateRecordsParams = {},
): Promise<PaginatedResponse<AffiliateTransferRecord>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateTransferRecord>>(
'/admin/affiliates/transfers',
{ params: recordParams(params) },
)
return data
}
export async function getUserOverview(
userId: number,
): Promise<AffiliateUserOverview> {
const { data } = await apiClient.get<AffiliateUserOverview>(
`/admin/affiliates/users/${userId}/overview`,
)
return data
}
export const affiliatesAPI = {
listUsers,
lookupUsers,
updateUserSettings,
clearUserSettings,
batchSetRate,
listInviteRecords,
listRebateRecords,
listTransferRecords,
getUserOverview,
}
export default affiliatesAPI
+86
View File
@@ -0,0 +1,86 @@
/**
* 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
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
}
): Promise<BasePaginationResponse<Announcement>> {
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
params: { page, page_size: pageSize, ...filters },
signal: options?.signal
})
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,
filters?: {
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
}
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
`/admin/announcements/${id}/read-status`,
{
params: { page, page_size: pageSize, ...filters },
signal: options?.signal
}
)
return data
}
const announcementsAPI = {
list,
getById,
create,
update,
delete: deleteAnnouncement,
getReadStatus
}
export default announcementsAPI
+70
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 }
+33
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
+82
View File
@@ -0,0 +1,82 @@
/**
* Admin operation audit log API.
*
* The audit log is admin-only (not exposed to end users). It records
* management-plane operations with masked header credentials and redacted
* request bodies. Entries cannot be deleted individually; the whole log can
* only be cleared with a fresh TOTP verification.
*/
import { apiClient } from '../client'
import type { PaginatedResponse } from '@/types'
export interface AuditLog {
id: number
created_at: string
actor_user_id?: number
actor_email: string
actor_role: string
auth_method: string
credential_masked: string
action: string
method: string
path: string
request_id: string
client_ip: string
user_agent: string
request_body?: string
status_code: number
latency_ms: number
extra?: Record<string, any>
}
export interface AuditLogQuery {
page?: number
page_size?: number
start_time?: string
end_time?: string
actor_user_id?: number
actor_email?: string
auth_method?: string
action?: string
method?: string
client_ip?: string
success?: string
q?: string
}
export type AuditLogListResponse = PaginatedResponse<AuditLog>
/**
* List audit logs (paginated, filterable).
*/
export async function list(params: AuditLogQuery): Promise<AuditLogListResponse> {
const { data } = await apiClient.get('/admin/audit-logs', { params })
return data
}
/**
* Get a single audit log entry (includes the redacted request body).
*/
export async function get(id: number): Promise<AuditLog> {
const { data } = await apiClient.get(`/admin/audit-logs/${id}`)
return data
}
/**
* Clear all audit logs. Requires a fresh TOTP code (verified server-side);
* unavailable when 2FA is not enabled for the operator.
* @param totpCode - current 6-digit TOTP code
*/
export async function clear(totpCode: string): Promise<{ deleted: number }> {
const { data } = await apiClient.post('/admin/audit-logs/clear', { totp_code: totpCode })
return data
}
export const auditAPI = {
list,
get,
clear
}
export default auditAPI
+187
View File
@@ -0,0 +1,187 @@
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
parts?: BackupPart[]
size_bytes: number
triggered_by: string
error_message?: string
started_at: string
finished_at?: string
expires_at?: string
progress?: string
restore_status?: string
restore_error?: string
restored_at?: string
}
export interface BackupPart {
index: number
s3_key: string
size_bytes: number
sha256?: string
}
export interface BackupDownloadPart {
index: number
size_bytes: number
url: string
}
export interface BackupDownloadResponse {
url?: string
parts?: BackupDownloadPart[]
}
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
}
// Async image object storage
//
// Shares the S3 client with backups, so `reuse_backup_s3` borrows the endpoint and
// credentials configured above and only keeps its own bucket/prefix.
export interface ImageStorageConfig {
enabled: boolean
reuse_backup_s3: boolean
bucket: string
prefix: string
public_base_url: string
presign_expiry_hours: number
max_download_bytes: number
endpoint: string
region: string
access_key_id: string
secret_access_key?: string
force_path_style: boolean
}
export interface ImageStorageConfigResponse {
config: ImageStorageConfig
secret_configured: boolean
}
export async function getImageStorageConfig(): Promise<ImageStorageConfigResponse> {
const { data } = await apiClient.get<ImageStorageConfigResponse>('/admin/backups/image-storage')
return data
}
export async function updateImageStorageConfig(
config: ImageStorageConfig,
): Promise<ImageStorageConfig> {
const { data } = await apiClient.put<ImageStorageConfig>('/admin/backups/image-storage', config)
return data
}
export async function testImageStorageConnection(
config: ImageStorageConfig,
): Promise<TestS3Response> {
const { data } = await apiClient.post<TestS3Response>(
'/admin/backups/image-storage/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 || {})
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<BackupDownloadResponse> {
const { data } = await apiClient.get<BackupDownloadResponse>(`/admin/backups/${id}/download-url`)
return data
}
// Restore
export async function restoreBackup(id: string, password: string): Promise<BackupRecord> {
const { data } = await apiClient.post<BackupRecord>(`/admin/backups/${id}/restore`, { password })
return data
}
export const backupAPI = {
getS3Config,
updateS3Config,
testS3Connection,
getImageStorageConfig,
updateImageStorageConfig,
testImageStorageConnection,
getSchedule,
updateSchedule,
createBackup,
listBackups,
getBackup,
deleteBackup,
getDownloadURL,
restoreBackup,
}
export default backupAPI
+357
View File
@@ -0,0 +1,357 @@
/**
* Admin Channel Monitor API endpoints
* Handles channel monitor (uptime/health) management for administrators
*/
import { apiClient } from '../client'
export type Provider =
| 'openai'
| 'anthropic'
| 'gemini'
| 'grok'
| 'antigravity'
| 'kimi'
| 'zhipu'
| 'deepseek'
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
export type APIMode = 'chat_completions' | 'responses'
/**
* probe = LLM 探活(默认);quota = 仅查关联账号用量(零 LLM 成本);
* quota_probe = 探活 + 配额快照挂主模型行。
*/
export type CheckMode = 'probe' | 'quota' | 'quota_probe'
/** 配额快照中的单个用量窗口(与后端 domain.MonitorQuotaTier 一致)。 */
export interface MonitorQuotaTier {
/** 5h | 7d | 7d-sonnet | 7d-fable | 30d | daily | weekly | total */
window: string
/** 同窗口多档时的机器标识(gemini shared/pro/flash、grok requests/tokens、antigravity 模型名) */
label?: string
used_percent: number
used?: number
limit?: number
/** RFC3339;空表示无重置时间 */
reset_at?: string
}
export interface MonitorBalance {
currency: string
balance: number
}
/** 归一化配额快照(与后端 domain.MonitorQuotaSnapshot 一致)。 */
export interface MonitorQuotaSnapshot {
/** usage | cn_quota | cn_balance */
source: string
success: boolean
tiers?: MonitorQuotaTier[]
balance?: number | null
balances?: MonitorBalance[]
currency?: string
plan_level?: string
/** 401/403 鉴权失败标记(推导为 failed 状态) */
credential_invalid?: boolean
error?: string
fetched_at: string
}
export interface ChannelMonitor {
id: number
name: string
provider: Provider
api_mode: APIMode
endpoint: string
api_key_masked: string
/**
* True when the stored encrypted API key cannot be decrypted (e.g. the
* encryption key has changed). Admin must re-edit the monitor to provide
* a fresh key. Backend skips checks for these monitors.
*/
api_key_decrypt_failed?: boolean
primary_model: string
extra_models: string[]
group_name: string
enabled: boolean
interval_seconds: number
/** 每次调度在 interval 基础上 ± [0, jitter] 的随机偏移(秒),0 = 固定间隔 */
jitter_seconds: number
last_checked_at: string | null
created_by: number
created_at: string
updated_at: string
/** Latest status of the primary model (empty when no history yet) */
primary_status: MonitorStatus | ''
/** Latest latency of the primary model in ms (null when no history yet) */
primary_latency_ms: number | null
/** Primary model 7-day availability percentage (0-100) */
availability_7d: number
/** Latest status per extra model (used for hover tooltip) */
extra_models_status: ExtraModelStatus[]
/** 请求自定义快照字段(高级设置) */
template_id: number | null
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
/** 检测模式:probe(默认)/ quota / quota_probe */
check_mode: CheckMode
/** 配额模式关联的账号 ID;探活模式为 null */
account_id: number | null
/** 主模型最近一次配额快照(配额模式;无历史时为 null) */
latest_quota?: MonitorQuotaSnapshot | null
}
export interface ExtraModelStatus {
model: string
status: MonitorStatus | ''
latency_ms: number | null
}
export interface ListParams {
page?: number
page_size?: number
provider?: Provider
enabled?: boolean
search?: string
}
export interface ListResponse {
items: ChannelMonitor[]
total: number
page: number
page_size: number
pages: number
}
export interface CreateParams {
name: string
provider: Provider
api_mode?: APIMode
/** 探活模式必填(base origin);quota 模式可留空 */
endpoint: string
/** 探活模式必填;quota 模式可留空 */
api_key: string
/** 缺省 probeantigravity 仅支持 quota */
check_mode?: CheckMode
/** 配额模式必填:数据源账号(provider 需与账号平台一致)。
* update 语义:>0=换绑,0=解绑(切回 probe 模式时前端发 0 清空存量关联);
* create 绝不发 0——后端会把 0 存成 &0 触发外键违约。 */
account_id?: number | null
primary_model: string
extra_models?: string[]
group_name?: string
enabled?: boolean
interval_seconds: number
jitter_seconds?: number
template_id?: number | null
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空;
// account_id=0 显式解绑关联账号(null = 不动,见 CreateParams 注释)
export type UpdateParams = Partial<CreateParams> & {
clear_template?: boolean
}
export interface CheckResult {
model: string
status: MonitorStatus
latency_ms: number | null
ping_latency_ms: number | null
message: string
checked_at: string
/** 配额模式(quota / quota_probe 主模型行)附带的配额快照 */
quota?: MonitorQuotaSnapshot | null
}
export interface RunNowResponse {
results: CheckResult[]
}
export interface HistoryItem {
id: number
model: string
status: MonitorStatus
latency_ms: number | null
ping_latency_ms: number | null
message: string
checked_at: string
/** 配额快照(配额模式行;探活行为空) */
quota?: MonitorQuotaSnapshot | null
}
export interface HistoryParams {
model?: string
limit?: number
}
export interface HistoryResponse {
items: HistoryItem[]
}
/**
* List channel monitors with pagination and filters
*/
export async function list(
params: ListParams = {},
options?: { signal?: AbortSignal }
): Promise<ListResponse> {
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitors', {
params,
signal: options?.signal,
})
return data
}
/**
* Get a channel monitor by ID
*/
export async function get(id: number): Promise<ChannelMonitor> {
const { data } = await apiClient.get<ChannelMonitor>(`/admin/channel-monitors/${id}`)
return data
}
/**
* Create a new channel monitor
*/
export async function create(params: CreateParams): Promise<ChannelMonitor> {
const { data } = await apiClient.post<ChannelMonitor>('/admin/channel-monitors', params)
return data
}
/**
* Duplicate a monitor without exposing its stored API key to the browser.
* Keep the operation key after ambiguous failures so a retry replays the
* original server-side operation instead of creating another monitor.
*/
const duplicateOperationKeys = new Map<string, string>()
interface DuplicateOperationScope {
adminID: string
key: string
}
function getCurrentAdminID(): string | null {
try {
const rawUser = globalThis.localStorage?.getItem('auth_user')
if (!rawUser) return null
const user: unknown = JSON.parse(rawUser)
if (typeof user !== 'object' || user === null) return null
const id = (user as { id?: unknown }).id
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
return String(id)
} catch {
return null
}
}
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
const adminID = getCurrentAdminID()
if (!adminID) return null
return {
adminID,
key: `sub2api:admin:channel-monitor-duplicate:${adminID}:${id}`,
}
}
function getStoredDuplicateOperationKey(storageKey: string): string | null {
try {
return globalThis.sessionStorage?.getItem(storageKey) ?? null
} catch {
return null
}
}
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
try {
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
else globalThis.sessionStorage?.removeItem(storageKey)
} catch {
// In-memory retry protection still works when browser storage is unavailable.
}
}
export async function duplicate(id: number): Promise<ChannelMonitor> {
const scope = duplicateOperationScope(id)
let idempotencyKey = scope
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
: null
if (!idempotencyKey) {
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
idempotencyKey = `channel-monitor-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
}
if (scope) {
duplicateOperationKeys.set(scope.key, idempotencyKey)
storeDuplicateOperationKey(scope.key, idempotencyKey)
}
const { data } = await apiClient.post<ChannelMonitor>(
`/admin/channel-monitors/${id}/duplicate`,
undefined,
{ headers: { 'Idempotency-Key': idempotencyKey } }
)
if (scope) {
duplicateOperationKeys.delete(scope.key)
storeDuplicateOperationKey(scope.key, null)
}
return data
}
/**
* Update an existing channel monitor.
* api_key field: empty string means "do not modify".
*/
export async function update(id: number, params: UpdateParams): Promise<ChannelMonitor> {
const { data } = await apiClient.put<ChannelMonitor>(`/admin/channel-monitors/${id}`, params)
return data
}
/**
* Delete a channel monitor
*/
export async function del(id: number): Promise<void> {
await apiClient.delete(`/admin/channel-monitors/${id}`)
}
/**
* Trigger an immediate manual check for a channel monitor.
* Returns the latest check results for primary + extra models.
*/
export async function runNow(id: number): Promise<RunNowResponse> {
const { data } = await apiClient.post<RunNowResponse>(`/admin/channel-monitors/${id}/run`)
return data
}
/**
* List historical check results for a monitor.
*/
export async function listHistory(
id: number,
params: HistoryParams = {}
): Promise<HistoryResponse> {
const { data } = await apiClient.get<HistoryResponse>(
`/admin/channel-monitors/${id}/history`,
{ params }
)
return data
}
export const channelMonitorAPI = {
list,
get,
create,
duplicate,
update,
del,
runNow,
listHistory,
}
export default channelMonitorAPI
@@ -0,0 +1,137 @@
/**
* Admin Channel Monitor Request Template API.
*
* 模板 = 一组可复用的 headers + 可选 body 覆盖配置。
* 应用到监控 = 拷贝快照;模板后续变动不自动同步,需手动点「应用到关联监控」刷新。
*/
import { apiClient } from '../client'
import type { APIMode, BodyOverrideMode, Provider } from './channelMonitor'
export interface ChannelMonitorTemplate {
id: number
name: string
provider: Provider
api_mode: APIMode
description: string
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
created_at: string
updated_at: string
/** 关联的监控数量(快照来自此模板,仅 template_id 匹配即可) */
associated_monitors: number
}
export interface ListParams {
provider?: Provider
api_mode?: APIMode
}
export interface ListResponse {
items: ChannelMonitorTemplate[]
}
export interface CreateParams {
name: string
provider: Provider
api_mode?: APIMode
description?: string
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
export interface UpdateParams {
name?: string
api_mode?: APIMode
description?: string
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
export interface ApplyResponse {
affected: number
}
export interface AssociatedMonitorBrief {
id: number
name: string
provider: Provider
api_mode: APIMode
enabled: boolean
}
export interface AssociatedMonitorsResponse {
items: AssociatedMonitorBrief[]
}
export async function list(params: ListParams = {}): Promise<ListResponse> {
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitor-templates', {
params,
})
return data
}
export async function get(id: number): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.get<ChannelMonitorTemplate>(
`/admin/channel-monitor-templates/${id}`,
)
return data
}
export async function create(params: CreateParams): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.post<ChannelMonitorTemplate>(
'/admin/channel-monitor-templates',
params,
)
return data
}
export async function update(id: number, params: UpdateParams): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.put<ChannelMonitorTemplate>(
`/admin/channel-monitor-templates/${id}`,
params,
)
return data
}
export async function del(id: number): Promise<void> {
await apiClient.delete(`/admin/channel-monitor-templates/${id}`)
}
/**
* Apply the template to the specified associated monitors (overwrite snapshot fields).
* monitorIds must be a non-empty subset of the template's associated monitors.
* Returns count of actually affected monitors.
*/
export async function apply(id: number, monitorIds: number[]): Promise<ApplyResponse> {
const { data } = await apiClient.post<ApplyResponse>(
`/admin/channel-monitor-templates/${id}/apply`,
{ monitor_ids: monitorIds },
)
return data
}
/**
* List monitors currently associated to this template (used by apply picker).
*/
export async function listAssociatedMonitors(id: number): Promise<AssociatedMonitorsResponse> {
const { data } = await apiClient.get<AssociatedMonitorsResponse>(
`/admin/channel-monitor-templates/${id}/monitors`,
)
return data
}
export const channelMonitorTemplateAPI = {
list,
get,
create,
update,
del,
apply,
listAssociatedMonitors,
}
export default channelMonitorTemplateAPI
+202
View File
@@ -0,0 +1,202 @@
/**
* Admin Channels API endpoints
* Handles channel management for administrators
*/
import { apiClient } from '../client'
import type { BillingMode, ChannelStatus, BillingModelSource } from '@/constants/channel'
export type { BillingMode } from '@/constants/channel'
export interface PricingInterval {
id?: number
min_tokens: number
max_tokens: number | null
tier_label: string
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
input_multiplier: number | null
output_multiplier: number | null
cache_write_multiplier: number | null
cache_read_multiplier: number | null
per_request_price: number | null
sort_order: number
}
export interface ChannelTimePricingPeriod {
start_time: string
end_time: string
multiplier: number
}
export interface ChannelTimePricing {
timezone: string
periods: ChannelTimePricingPeriod[]
}
export interface ChannelModelPricing {
id?: number
platform: string
models: string[]
billing_mode: BillingMode
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
fast_multiplier?: number | null
flex_multiplier?: number | null
image_input_price: number | null
image_output_price: number | null
per_request_price: number | null
intervals: PricingInterval[]
time_pricing: ChannelTimePricing | null
}
export interface AccountStatsPricingRule {
id?: number
name: string
group_ids: number[]
account_ids: number[]
pricing: ChannelModelPricing[]
}
export interface Channel {
id: number
name: string
description: string
status: ChannelStatus
billing_model_source: BillingModelSource
restrict_models: boolean
features_config?: Record<string, unknown>
group_ids: number[]
model_pricing: ChannelModelPricing[]
model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
apply_pricing_to_account_stats: boolean
account_stats_pricing_rules: AccountStatsPricingRule[]
created_at: string
updated_at: string
}
export interface CreateChannelRequest {
name: string
description?: string
group_ids?: number[]
model_pricing?: ChannelModelPricing[]
model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string
restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
}
export interface UpdateChannelRequest {
name?: string
description?: string
status?: string
group_ids?: number[]
model_pricing?: ChannelModelPricing[]
model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string
restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
}
interface PaginatedResponse<T> {
items: T[]
total: number
}
/**
* List channels with pagination
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: string
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: { signal?: AbortSignal }
): Promise<PaginatedResponse<Channel>> {
const { data } = await apiClient.get<PaginatedResponse<Channel>>('/admin/channels', {
params: {
page,
page_size: pageSize,
...filters
},
signal: options?.signal
})
return data
}
/**
* Get channel by ID
*/
export async function getById(id: number): Promise<Channel> {
const { data } = await apiClient.get<Channel>(`/admin/channels/${id}`)
return data
}
/**
* Create a new channel
*/
export async function create(req: CreateChannelRequest): Promise<Channel> {
const { data } = await apiClient.post<Channel>('/admin/channels', req)
return data
}
/**
* Update a channel
*/
export async function update(id: number, req: UpdateChannelRequest): Promise<Channel> {
const { data } = await apiClient.put<Channel>(`/admin/channels/${id}`, req)
return data
}
/**
* Delete a channel
*/
export async function remove(id: number): Promise<void> {
await apiClient.delete(`/admin/channels/${id}`)
}
export interface ModelDefaultPricing {
found: boolean
input_price?: number // per-token price
output_price?: number
cache_write_price?: number
cache_read_price?: number
image_input_price?: number
image_output_price?: number
}
export async function getModelDefaultPricing(model: string): Promise<ModelDefaultPricing> {
const { data } = await apiClient.get<ModelDefaultPricing>('/admin/channels/model-pricing', {
params: { model }
})
return data
}
export interface SyncPricingModelsResult {
models: string[]
}
/**
* Fetch the latest model names from the LiteLLM pricing catalog for the given platform
*/
export async function syncPricingModels(platform: string): Promise<SyncPricingModelsResult> {
const { data } = await apiClient.get<SyncPricingModelsResult>('/admin/channels/pricing/sync-models', {
params: { platform }
})
return data
}
const channelsAPI = { list, getById, create, update, remove, getModelDefaultPricing, syncPricingModels }
export default channelsAPI
+70
View File
@@ -0,0 +1,70 @@
/**
* Admin CN providers (Kimi / Zhipu / DeepSeek) API endpoints.
* Coding-plan rolling-window quota probe + payg balance probe.
*/
import { apiClient } from '../client'
/** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */
export interface CNQuotaTier {
window: '5h' | 'weekly'
used_percent: number
reset_at?: string
}
/** Coding Plan 额度探测结果(kimi / zhipu),对齐后端 CNProviderQuotaProbeResult。 */
export interface CNProviderQuotaProbeResult {
provider: string
source?: string
success: boolean
credential_valid: boolean
tiers?: CNQuotaTier[]
plan_level?: string
status_code?: number
fetched_at: number
persisted: boolean
error?: string
}
/** 单币种余额明细(deepseek 双币种账号含 CNY + USD 两条)。 */
export interface CNProviderBalanceEntry {
currency: string
balance: number
}
/** payg 余额探测结果(kimi / deepseek),对齐后端 CNProviderBalanceResult。 */
export interface CNProviderBalanceResult {
provider: string
success: boolean
/** 主币种余额(balances 首条,兼容单币种展示)。 */
balance: number
currency?: string
/** 多币种明细;缺省时按主币种展示。 */
balances?: CNProviderBalanceEntry[]
available: boolean
status_code?: number
fetched_at: number
persisted: boolean
error?: string
}
/** 查询 Coding Plan 滚动窗口用量(5h + weekly)。 */
export async function queryQuota(id: number): Promise<CNProviderQuotaProbeResult> {
const { data } = await apiClient.get<CNProviderQuotaProbeResult>(
`/admin/cn-providers/accounts/${id}/quota`
)
return data
}
/** 查询 payg 账号余额。 */
export async function queryBalance(id: number): Promise<CNProviderBalanceResult> {
const { data } = await apiClient.get<CNProviderBalanceResult>(
`/admin/cn-providers/accounts/${id}/balance`
)
return data
}
export default {
queryQuota,
queryBalance
}
+42
View File
@@ -0,0 +1,42 @@
import { apiClient } from '@/api/client'
export interface AdminComplianceAcknowledgement {
version: string
document_zh: string
document_en: string
admin_user_id: number
ip_address?: string
user_agent?: string
accepted_at: string
}
export interface AdminComplianceStatus {
required: boolean
version: string
document_path_zh: string
document_path_en: string
document_url_zh: string
document_url_en: string
ack_phrase_zh: string
ack_phrase_en: string
acknowledgement?: AdminComplianceAcknowledgement
}
export interface AcceptAdminComplianceRequest {
phrase: string
language: string
}
export const adminComplianceAPI = {
async getStatus(): Promise<AdminComplianceStatus> {
const { data } = await apiClient.get<AdminComplianceStatus>('/admin/compliance')
return data
},
async accept(payload: AcceptAdminComplianceRequest): Promise<AdminComplianceStatus> {
const { data } = await apiClient.post<AdminComplianceStatus>('/admin/compliance/accept', payload)
return data
}
}
export default adminComplianceAPI
+344
View File
@@ -0,0 +1,344 @@
/**
* Admin Dashboard API endpoints
* Provides system-wide statistics and metrics
*/
import { apiClient } from '../client'
import type {
DashboardStats,
TrendDataPoint,
ModelStat,
GroupStat,
ApiKeyUsageTrendPoint,
UserUsageTrendPoint,
UserSpendingRankingResponse,
UserBreakdownItem,
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
upstream_model_mismatch?: boolean
}
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
model_source?: 'requested' | 'upstream' | 'mapping'
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
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
upstream_model_mismatch?: boolean
}
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
}
export interface UserBreakdownParams {
start_date?: string
end_date?: string
group_id?: number
model?: string
model_source?: 'requested' | 'upstream' | 'mapping'
endpoint?: string
endpoint_type?: 'inbound' | 'upstream' | 'path'
limit?: number
// Sort column for the ranking (allowlisted server-side; falls back to actual_cost)
sort_by?: 'total_tokens' | 'input_tokens' | 'output_tokens' | 'cache_tokens' | 'requests' | 'cost' | 'actual_cost'
// Additional filter conditions
user_id?: number
api_key_id?: number
account_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
export interface UserBreakdownResponse {
users: UserBreakdownItem[]
start_date: string
end_date: string
}
export async function getUserBreakdown(params: UserBreakdownParams): Promise<UserBreakdownResponse> {
const { data } = await apiClient.get<UserBreakdownResponse>('/admin/dashboard/user-breakdown', {
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 PlatformUsage {
platform: string
today_actual_cost: number
total_actual_cost: number
}
export interface BatchUserUsageStats {
user_id: number
today_actual_cost: number
total_actual_cost: number
by_platform?: PlatformUsage[]
}
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
+332
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
+137
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
+72
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 }
+228
View File
@@ -0,0 +1,228 @@
/**
* Admin Grok/xAI API endpoints
* Handles xAI OAuth flows for administrators.
*/
import { apiClient } from '../client'
import type { GrokBillingSummary, GrokQuotaWindow, WindowStats } from '@/types'
export type { GrokBillingSummary, GrokQuotaWindow } from '@/types'
export interface GrokAuthUrlResponse {
auth_url: string
session_id: string
state: string
}
export interface GrokAuthUrlRequest {
proxy_id?: number
redirect_uri?: string
}
export interface GrokOAuthCapabilities {
password_auth_enabled: boolean
}
const GROK_AUTHORIZATION_TIMEOUT_MS = 120_000
export async function getCapabilities(): Promise<GrokOAuthCapabilities> {
const { data } = await apiClient.get<GrokOAuthCapabilities>('/admin/grok/oauth/capabilities')
return data
}
export interface GrokExchangeCodeRequest {
session_id: string
state: string
code: string
proxy_id?: number
redirect_uri?: string
}
export interface GrokTokenInfo {
access_token?: string
refresh_token?: string
token_type?: string
id_token?: string
expires_at?: number | string
expires_in?: number
scope?: string
client_id?: string
email?: string
sub?: string
team_id?: string
subscription_tier?: string
entitlement_status?: string
[key: string]: unknown
}
export interface GrokSSOToOAuthRequest {
sso_tokens: string[]
name?: string
notes?: string | null
proxy_id?: number | null
group_ids?: number[]
credentials?: Record<string, unknown>
extra?: Record<string, unknown>
concurrency?: number
load_factor?: number
priority?: number
rate_multiplier?: number
expires_at?: number | null
auto_pause_on_expired?: boolean
}
export interface GrokSSOToOAuthItemResult {
index: number
name?: string
email?: string
account?: unknown
error?: string
}
export interface GrokSSOToOAuthResponse {
created: GrokSSOToOAuthItemResult[]
failed: GrokSSOToOAuthItemResult[]
}
const GROK_SSO_IMPORT_CONCURRENCY = 3
const GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS = 90_000
const GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS = 90_000
export function getGrokSSOImportTimeout(keyCount: number): number {
const batches = Math.ceil(Math.max(1, keyCount) / GROK_SSO_IMPORT_CONCURRENCY)
return batches * GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS + GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS
}
export interface GrokQuotaSnapshot {
requests?: GrokQuotaWindow | null
tokens?: GrokQuotaWindow | null
retry_after_seconds?: number | null
subscription_tier?: string
entitlement_status?: string
status_code?: number
headers?: Record<string, string>
headers_observed: boolean
observation_source?: string
last_probe_at?: string
last_headers_seen_at?: string
updated_at: string
}
export interface GrokQuotaProbeResult {
source: 'active_probe' | 'billing_probe' | 'hybrid_probe'
model?: string
billing?: GrokBillingSummary | null
snapshot?: GrokQuotaSnapshot | null
local_usage_24h?: WindowStats | null
local_usage_7d?: WindowStats | null
local_usage_monthly?: WindowStats | null
status_code?: number
headers_observed: boolean
reset_supported: boolean
fetched_at: number
persisted?: boolean
probe_error?: string
}
export interface GrokQuotaResetResult {
supported: boolean
code: string
message: string
}
export async function generateAuthUrl(
payload: GrokAuthUrlRequest
): Promise<GrokAuthUrlResponse> {
const { data } = await apiClient.post<GrokAuthUrlResponse>(
'/admin/grok/oauth/auth-url',
payload
)
return data
}
export async function exchangeCode(payload: GrokExchangeCodeRequest): Promise<GrokTokenInfo> {
const { data } = await apiClient.post<GrokTokenInfo>(
'/admin/grok/oauth/exchange-code',
payload
)
return data
}
export async function refreshGrokToken(
refreshToken: string,
proxyId?: number | null
): Promise<GrokTokenInfo> {
const payload: Record<string, unknown> = { refresh_token: refreshToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>(
'/admin/grok/oauth/refresh-token',
payload
)
return data
}
export async function queryQuota(id: number): Promise<GrokQuotaProbeResult> {
const { data } = await apiClient.get<GrokQuotaProbeResult>(`/admin/grok/accounts/${id}/quota`)
return data
}
export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
const { data } = await apiClient.post<GrokQuotaResetResult>(`/admin/grok/accounts/${id}/reset-quota`)
return data
}
export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<GrokSSOToOAuthResponse> {
const { data } = await apiClient.post<GrokSSOToOAuthResponse>(
'/admin/grok/sso-to-oauth',
payload,
{ timeout: getGrokSSOImportTimeout(payload.sso_tokens.length) }
)
return data
}
/** Validate a browser SSO cookie and convert to Build OAuth tokens (no raw SSO stored). */
export async function validateSSOToken(
ssoToken: string,
proxyId?: number | null
): Promise<GrokTokenInfo> {
const payload: Record<string, unknown> = { sso_token: ssoToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/sso-token', payload, {
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
})
return data
}
/**
* Password login → ephemeral SSO → Build OAuth.
* Password is only sent over the wire for this call; never persist it in credentials.
*/
export async function authorizePassword(
emailAndPassword: string,
proxyId?: number | null
): Promise<GrokTokenInfo> {
// Format: email----password (password may contain dashes).
const sep = '----'
const idx = emailAndPassword.indexOf(sep)
const email = (idx >= 0 ? emailAndPassword.slice(0, idx) : emailAndPassword).trim()
const password = idx >= 0 ? emailAndPassword.slice(idx + sep.length) : ''
const payload: Record<string, unknown> = { email, password }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/password', payload, {
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
})
return data
}
export default {
generateAuthUrl,
getCapabilities,
exchangeCode,
refreshGrokToken,
queryQuota,
resetQuota,
createFromSSO,
validateSSOToken,
authorizePassword,
}
+504
View File
@@ -0,0 +1,504 @@
/**
* Admin Groups API endpoints
* Handles API key group management for administrators
*/
import { apiClient } from '../client'
import type {
AdminGroup,
GroupPlatform,
CompositeModelRoute,
CompositeModelRouteInput,
CompositeRoutePreviewRequest,
CompositeRouteDecision,
CreateGroupRequest,
UpdateGroupRequest,
PaginatedResponse
} from '@/types'
export interface LiveCapability {
supported: boolean
reason?: string
}
/**
* 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
sort_by?: string
sort_order?: 'asc' | 'desc'
},
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 ALL groups including disabled ones — used by the API Key group filter so
* that admins can filter users whose keys are still bound to a now-disabled group.
*/
export async function getAllIncludingInactive(): Promise<AdminGroup[]> {
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
params: { include_inactive: true }
})
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)
}
/** 获取当前 Sub2API 服务端的 Live 运行环境能力。 */
export async function getLiveCapability(): Promise<LiveCapability> {
const { data } = await apiClient.get<LiveCapability>('/admin/groups/live-capability')
return data
}
/**
* 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
}
/**
* Get candidate models for custom /v1/models list.
* id=0 returns platform default models for create flow.
*/
export async function getModelsListCandidates(
id: number,
platform?: GroupPlatform
): Promise<string[]> {
const { data } = await apiClient.get<{ models: string[] }>(
`/admin/groups/${id}/models-list-candidates`,
{
params: platform ? { platform } : undefined
}
)
return data.models || []
}
/**
* 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
}
/**
* Duplicate a group on the server so configuration that is not present in the
* list response is preserved. Keep the operation key after ambiguous failures
* so a retry replays the original operation instead of creating another group.
*/
const duplicateOperationKeys = new Map<string, string>()
interface DuplicateOperationScope {
adminID: string
key: string
}
function getCurrentAdminID(): string | null {
try {
const rawUser = globalThis.localStorage?.getItem('auth_user')
if (!rawUser) return null
const user: unknown = JSON.parse(rawUser)
if (typeof user !== 'object' || user === null) return null
const id = (user as { id?: unknown }).id
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
return String(id)
} catch {
return null
}
}
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
const adminID = getCurrentAdminID()
if (!adminID) return null
return {
adminID,
key: `sub2api:admin:group-duplicate:${adminID}:${id}`
}
}
function getStoredDuplicateOperationKey(storageKey: string): string | null {
try {
return globalThis.sessionStorage?.getItem(storageKey) ?? null
} catch {
return null
}
}
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
try {
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
else globalThis.sessionStorage?.removeItem(storageKey)
} catch {
// In-memory retry protection still works when browser storage is unavailable.
}
}
export async function duplicate(id: number): Promise<AdminGroup> {
const scope = duplicateOperationScope(id)
let idempotencyKey = scope
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
: null
if (!idempotencyKey) {
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
}
if (scope) {
duplicateOperationKeys.set(scope.key, idempotencyKey)
storeDuplicateOperationKey(scope.key, idempotencyKey)
}
const { data } = await apiClient.post<AdminGroup>(`/admin/groups/${id}/duplicate`, undefined, {
headers: { 'Idempotency-Key': idempotencyKey }
})
if (scope) {
duplicateOperationKeys.delete(scope.key)
storeDuplicateOperationKey(scope.key, null)
}
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
}
export async function listCompositeRoutes(id: number): Promise<CompositeModelRoute[]> {
const { data } = await apiClient.get<CompositeModelRoute[]>(`/admin/groups/${id}/composite-routes`)
return data
}
export async function createCompositeRoute(
id: number,
route: CompositeModelRouteInput
): Promise<CompositeModelRoute> {
const { data } = await apiClient.post<CompositeModelRoute>(
`/admin/groups/${id}/composite-routes`,
route
)
return data
}
export async function updateCompositeRoute(
id: number,
routeId: number,
route: CompositeModelRouteInput
): Promise<CompositeModelRoute> {
const { data } = await apiClient.put<CompositeModelRoute>(
`/admin/groups/${id}/composite-routes/${routeId}`,
route
)
return data
}
export async function deleteCompositeRoute(
id: number,
routeId: number
): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(
`/admin/groups/${id}/composite-routes/${routeId}`
)
return data
}
export async function previewCompositeRoute(
id: number,
request: CompositeRoutePreviewRequest
): Promise<CompositeRouteDecision> {
const { data } = await apiClient.post<CompositeRouteDecision>(
`/admin/groups/${id}/composite-routes/preview`,
request
)
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 | null
rpm_override?: number | null
}
/**
* 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
* Only touches rate_multiplier column; preserves rpm_override on existing rows.
*/
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
}
/**
* RPM override entry for a user in a group
*/
export interface GroupRPMOverrideEntry {
user_id: number
user_name: string
user_email: string
user_notes: string
user_status: string
rpm_override: number
}
/**
* Get RPM overrides for users in a group (subset of rate-multipliers endpoint).
*/
export async function getGroupRPMOverrides(id: number): Promise<GroupRPMOverrideEntry[]> {
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
`/admin/groups/${id}/rate-multipliers`
)
return data
.filter(e => e.rpm_override != null)
.map(e => ({
user_id: e.user_id,
user_name: e.user_name,
user_email: e.user_email,
user_notes: e.user_notes,
user_status: e.user_status,
rpm_override: e.rpm_override as number
}))
}
/**
* Batch set RPM overrides for users in a group.
* Only touches rpm_override column; preserves rate_multiplier on existing rows.
*/
export async function batchSetGroupRPMOverrides(
id: number,
entries: Array<{ user_id: number; rpm_override: number }>
): Promise<{ message: string }> {
const { data } = await apiClient.put<{ message: string }>(
`/admin/groups/${id}/rpm-overrides`,
{ entries }
)
return data
}
/**
* Clear all RPM overrides for a group (preserves rate_multiplier).
*/
export async function clearGroupRPMOverrides(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rpm-overrides`)
return data
}
/**
* Get usage summary (today + yesterday + cumulative cost) for all groups
* @returns Array of group usage summaries
*/
export async function getUsageSummary(): Promise<
{ group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[]
> {
const { data } = await apiClient.get<
{ group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[]
>('/admin/groups/usage-summary')
return data
}
/**
* Get capacity summary (concurrency/sessions/RPM) for all active groups
*/
export async function getCapacitySummary(): Promise<
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
> {
const { data } = await apiClient.get<
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
>('/admin/groups/capacity-summary')
return data
}
export const groupsAPI = {
list,
getAll,
getByPlatform,
getAllIncludingInactive,
getLiveCapability,
getById,
getModelsListCandidates,
create,
duplicate,
update,
delete: deleteGroup,
toggleStatus,
getStats,
getGroupApiKeys,
listCompositeRoutes,
createCompositeRoute,
updateCompositeRoute,
deleteCompositeRoute,
previewCompositeRoute,
getGroupRateMultipliers,
clearGroupRateMultipliers,
batchSetGroupRateMultipliers,
getGroupRPMOverrides,
clearGroupRPMOverrides,
batchSetGroupRPMOverrides,
updateSortOrder,
getUsageSummary,
getCapacitySummary
}
export default groupsAPI
+120
View File
@@ -0,0 +1,120 @@
/**
* 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 grokAPI from './grok'
import cnProvidersAPI from './cnProviders'
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'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment'
import affiliatesAPI from './affiliates'
import riskControlAPI from './riskControl'
import adminComplianceAPI from './compliance'
import auditAPI from './audit'
/**
* 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,
grok: grokAPI,
cnProviders: cnProvidersAPI,
userAttributes: userAttributesAPI,
ops: opsAPI,
errorPassthrough: errorPassthroughAPI,
dataManagement: dataManagementAPI,
apiKeys: apiKeysAPI,
scheduledTests: scheduledTestsAPI,
backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI,
channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI,
payment: adminPaymentAPI,
affiliates: affiliatesAPI,
riskControl: riskControlAPI,
compliance: adminComplianceAPI,
audit: auditAPI
}
export {
dashboardAPI,
usersAPI,
groupsAPI,
accountsAPI,
proxiesAPI,
redeemAPI,
promoAPI,
announcementsAPI,
settingsAPI,
systemAPI,
subscriptionsAPI,
usageAPI,
geminiAPI,
antigravityAPI,
grokAPI,
cnProvidersAPI,
userAttributesAPI,
opsAPI,
errorPassthroughAPI,
dataManagementAPI,
apiKeysAPI,
scheduledTestsAPI,
backupAPI,
tlsFingerprintProfileAPI,
channelsAPI,
channelMonitorAPI,
channelMonitorTemplateAPI,
adminPaymentAPI,
affiliatesAPI,
riskControlAPI,
adminComplianceAPI,
auditAPI
}
export default adminAPI
// Re-export types used by components
export type { AuditLog, AuditLogQuery, AuditLogListResponse } from './audit'
export type { BalanceHistoryItem } from './users'
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl'
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
/**
* Admin Payment API endpoints
* Handles payment management operations for administrators
*/
import { apiClient } from '../client'
import type {
DashboardStats,
PaymentOrder,
PaymentChannel,
SubscriptionPlan,
ProviderInstance
} from '@/types/payment'
import type { BasePaginationResponse } from '@/types'
/** Admin-facing payment config returned by GET /admin/payment/config */
export interface AdminPaymentConfig {
enabled: boolean
min_amount: number
max_amount: number
daily_limit: number
order_timeout_minutes: number
max_pending_orders: number
enabled_payment_types: string[]
balance_disabled: boolean
balance_recharge_multiplier: number
subscription_usd_to_cny_rate: number
recharge_fee_rate: number
load_balance_strategy: string
product_name_prefix: string
product_name_suffix: string
help_image_url: string
help_text: string
}
/** Fields accepted by PUT /admin/payment/config (all optional via pointer semantics) */
export interface UpdatePaymentConfigRequest {
enabled?: boolean
min_amount?: number
max_amount?: number
daily_limit?: number
order_timeout_minutes?: number
max_pending_orders?: number
enabled_payment_types?: string[]
balance_disabled?: boolean
balance_recharge_multiplier?: number
subscription_usd_to_cny_rate?: number
recharge_fee_rate?: number
load_balance_strategy?: string
product_name_prefix?: string
product_name_suffix?: string
help_image_url?: string
help_text?: string
}
export interface RefundResult {
success: boolean
warning?: string
require_force?: boolean
balance_deducted?: number
subscription_days_deducted?: number
}
export const adminPaymentAPI = {
// ==================== Config ====================
/** Get payment configuration (admin view) */
getConfig() {
return apiClient.get<AdminPaymentConfig>('/admin/payment/config')
},
/** Update payment configuration */
updateConfig(data: UpdatePaymentConfigRequest) {
return apiClient.put('/admin/payment/config', data)
},
// ==================== Dashboard ====================
/** Get payment dashboard statistics */
getDashboard(days?: number) {
return apiClient.get<DashboardStats>('/admin/payment/dashboard', {
params: days ? { days } : undefined
})
},
// ==================== Orders ====================
/** Get all orders (paginated, with filters) */
getOrders(params?: {
page?: number
page_size?: number
status?: string
payment_type?: string
user_id?: number
keyword?: string
start_date?: string
end_date?: string
order_type?: string
}) {
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/admin/payment/orders', { params })
},
/** Get a specific order by ID */
getOrder(id: number) {
return apiClient.get<PaymentOrder>(`/admin/payment/orders/${id}`)
},
/** Cancel an order (admin) */
cancelOrder(id: number) {
return apiClient.post(`/admin/payment/orders/${id}/cancel`)
},
/** Retry recharge for a failed order */
retryRecharge(id: number) {
return apiClient.post(`/admin/payment/orders/${id}/retry`)
},
/** Process a refund */
refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean; force?: boolean }) {
return apiClient.post<RefundResult>(`/admin/payment/orders/${id}/refund`, data)
},
/** Query and finalize a pending refund */
queryRefund(id: number) {
return apiClient.post<RefundResult>(`/admin/payment/orders/${id}/refund/query`)
},
// ==================== Channels ====================
/** Get all payment channels */
getChannels() {
return apiClient.get<PaymentChannel[]>('/admin/payment/channels')
},
/** Create a payment channel */
createChannel(data: Partial<PaymentChannel>) {
return apiClient.post<PaymentChannel>('/admin/payment/channels', data)
},
/** Update a payment channel */
updateChannel(id: number, data: Partial<PaymentChannel>) {
return apiClient.put<PaymentChannel>(`/admin/payment/channels/${id}`, data)
},
/** Delete a payment channel */
deleteChannel(id: number) {
return apiClient.delete(`/admin/payment/channels/${id}`)
},
// ==================== Subscription Plans ====================
/** Get all subscription plans */
getPlans() {
return apiClient.get<SubscriptionPlan[]>('/admin/payment/plans')
},
/** Create a subscription plan */
createPlan(data: Record<string, unknown>) {
return apiClient.post<SubscriptionPlan>('/admin/payment/plans', data)
},
/** Update a subscription plan */
updatePlan(id: number, data: Record<string, unknown>) {
return apiClient.put<SubscriptionPlan>(`/admin/payment/plans/${id}`, data)
},
/** Delete a subscription plan */
deletePlan(id: number) {
return apiClient.delete(`/admin/payment/plans/${id}`)
},
// ==================== Provider Instances ====================
/** Get all provider instances */
getProviders() {
return apiClient.get<ProviderInstance[]>('/admin/payment/providers')
},
/** Create a provider instance */
createProvider(data: Partial<ProviderInstance>) {
return apiClient.post<ProviderInstance>('/admin/payment/providers', data)
},
/** Update a provider instance */
updateProvider(id: number, data: Partial<ProviderInstance>) {
return apiClient.put<ProviderInstance>(`/admin/payment/providers/${id}`, data)
},
/** Delete a provider instance */
deleteProvider(id: number) {
return apiClient.delete(`/admin/payment/providers/${id}`)
}
}
export default adminPaymentAPI
+75
View File
@@ -0,0 +1,75 @@
/**
* 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
sort_by?: string
sort_order?: 'asc' | 'desc'
},
options?: {
signal?: AbortSignal
}
): Promise<BasePaginationResponse<PromoCode>> {
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
params: { page, page_size: pageSize, ...filters },
signal: options?.signal
})
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
+277
View File
@@ -0,0 +1,277 @@
/**
* 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' | 'expired'
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
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' | 'expired'
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}
}): 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, sort_by, sort_order } = options.filters
if (protocol) params.protocol = protocol
if (status) params.status = status
if (search) params.search = search
if (sort_by) params.sort_by = sort_by
if (sort_order) params.sort_order = sort_order
}
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
+206
View File
@@ -0,0 +1,206 @@
/**
* Admin Redeem Codes API endpoints
* Handles redeem code generation and management for administrators
*/
import { apiClient } from '../client'
import type {
RedeemCode,
GenerateRedeemCodesRequest,
BatchUpdateRedeemCodeFields,
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' | 'disabled'
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
},
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)
* @param expiresInDays - Days before the code itself expires
* @returns Array of generated redeem codes
*/
export async function generate(
count: number,
type: RedeemCodeType,
value: number,
groupId?: number | null,
validityDays?: number,
expiresInDays?: number | null
): Promise<RedeemCode[]> {
const payload: GenerateRedeemCodesRequest = {
count,
type,
value
}
// 订阅类型专用字段
if (type === 'subscription') {
payload.group_id = groupId
if (validityDays && validityDays > 0) {
payload.validity_days = validityDays
}
}
if (expiresInDays && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
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
}
/**
* Batch update selected redeem code fields
* @param ids - Array of redeem code IDs
* @param fields - Field collection to update
* @returns Updated count
*/
export async function batchUpdate(
ids: number[],
fields: BatchUpdateRedeemCodeFields
): Promise<{
updated: number
message: string
}> {
const { data } = await apiClient.post<{
updated: number
message: string
}>('/admin/redeem-codes/batch-update', { ids, fields })
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?: 'used' | 'expired' | 'unused' | 'disabled'
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}): 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,
batchUpdate,
expire,
getStats,
exportCodes
}
export default redeemAPI
+300
View File
@@ -0,0 +1,300 @@
import { apiClient } from '../client'
export type ModerationMode = 'off' | 'observe' | 'pre_block'
export type KeywordBlockingMode = 'keyword_only' | 'keyword_and_api' | 'api_only'
export type ContentModerationModelFilterType = 'all' | 'include' | 'exclude'
export interface ContentModerationModelFilter {
type: ContentModerationModelFilterType
models: string[]
}
export interface ContentModerationConfig {
enabled: boolean
mode: ModerationMode
base_url: string
model: string
proxy_id: number | null
api_key_configured: boolean
api_key_masked: string
api_key_count: number
api_key_masks: string[]
api_key_statuses: ContentModerationAPIKeyStatus[]
timeout_ms: number
sample_rate: number
all_groups: boolean
group_ids: number[]
record_non_hits: boolean
thresholds: Record<string, number>
worker_count: number
queue_size: number
block_status: number
block_message: string
email_on_hit: boolean
auto_ban_enabled: boolean
ban_threshold: number
violation_window_hours: number
retry_count: number
hit_retention_days: number
non_hit_retention_days: number
pre_hash_check_enabled: boolean
blocked_keywords: string[]
keyword_blocking_mode: KeywordBlockingMode
model_filter: ContentModerationModelFilter
cyber_policy_exclude_from_ban_count: boolean
}
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
export interface ContentModerationAPIKeyStatus {
index: number
key_hash: string
masked: string
status: ContentModerationAPIKeyStatusValue
failure_count: number
success_count: number
last_error: string
last_checked_at?: string
frozen_until?: string
last_latency_ms: number
last_http_status: number
last_tested: boolean
configured: boolean
}
export interface TestContentModerationAPIKeysPayload {
api_keys?: string[]
base_url?: string
model?: string
timeout_ms?: number
// null/undefined 沿用已保存配置的代理;0 强制直连;>0 指定代理
proxy_id?: number
prompt?: string
images?: string[]
}
export interface TestContentModerationAPIKeysResponse {
items: ContentModerationAPIKeyStatus[]
audit_result?: ContentModerationTestAuditResult
image_count: number
}
export interface ContentModerationTestAuditResult {
flagged: boolean
highest_category: string
highest_score: number
composite_score: number
category_scores: Record<string, number>
thresholds: Record<string, number>
}
export interface UpdateContentModerationConfig {
enabled?: boolean
mode?: ModerationMode
base_url?: string
model?: string
// undefined 不修改;0 清除(直连);>0 指定代理
proxy_id?: number
api_key?: string
api_keys?: string[]
api_keys_mode?: 'append' | 'replace'
delete_api_key_hashes?: string[]
clear_api_key?: boolean
timeout_ms?: number
sample_rate?: number
all_groups?: boolean
group_ids?: number[]
record_non_hits?: boolean
thresholds?: Record<string, number>
worker_count?: number
queue_size?: number
block_status?: number
block_message?: string
email_on_hit?: boolean
auto_ban_enabled?: boolean
ban_threshold?: number
violation_window_hours?: number
retry_count?: number
hit_retention_days?: number
non_hit_retention_days?: number
pre_hash_check_enabled?: boolean
blocked_keywords?: string[]
keyword_blocking_mode?: KeywordBlockingMode
model_filter?: ContentModerationModelFilter
cyber_policy_exclude_from_ban_count?: boolean
}
export interface ContentModerationRuntimeStatus {
enabled: boolean
risk_control_enabled: boolean
mode: ModerationMode
worker_count: number
max_workers: number
active_workers: number
idle_workers: number
queue_size: number
queue_length: number
queue_usage_percent: number
enqueued: number
dropped: number
processed: number
errors: number
pre_block_active: number
pre_block_checked: number
pre_block_allowed: number
pre_block_blocked: number
pre_block_errors: number
pre_block_avg_latency_ms: number
pre_block_api_key_active: number
pre_block_api_key_available_count: number
pre_block_api_key_total_calls: number
pre_block_api_key_loads: ContentModerationAPIKeyLoad[]
api_key_statuses: ContentModerationAPIKeyStatus[]
flagged_hash_count: number
last_cleanup_at?: string
last_cleanup_deleted_hit: number
last_cleanup_deleted_non_hit: number
}
export interface ContentModerationAPIKeyLoad {
index: number
key_hash: string
masked: string
status: ContentModerationAPIKeyStatusValue
active: number
total: number
success: number
errors: number
avg_latency_ms: number
last_latency_ms: number
last_http_status: number
}
export interface ContentModerationLog {
id: number
request_id: string
user_id: number | null
user_email: string
api_key_id: number | null
api_key_name: string
group_id: number | null
group_name: string
endpoint: string
provider: string
model: string
mode: string
action: string
flagged: boolean
highest_category: string
highest_score: number
matched_keyword: string
category_scores: Record<string, number>
threshold_snapshot: Record<string, number>
input_excerpt: string
upstream_latency_ms: number | null
error: string
violation_count: number
auto_banned: boolean
email_sent: boolean
user_status: string
queue_delay_ms: number | null
created_at: string
}
export interface ListContentModerationLogsParams {
page?: number
page_size?: number
result?: string
group_id?: number
endpoint?: string
search?: string
from?: string
to?: string
}
export interface ContentModerationLogsResponse {
items: ContentModerationLog[]
total: number
page: number
page_size: number
pages: number
}
export interface ContentModerationUnbanUserResponse {
user_id: number
status: string
}
export interface DeleteFlaggedHashResponse {
input_hash: string
deleted: boolean
}
export interface ClearFlaggedHashesResponse {
deleted: number
}
export async function getConfig(): Promise<ContentModerationConfig> {
const { data } = await apiClient.get<ContentModerationConfig>('/admin/risk-control/config')
return data
}
export async function updateConfig(
payload: UpdateContentModerationConfig
): Promise<ContentModerationConfig> {
const { data } = await apiClient.put<ContentModerationConfig>('/admin/risk-control/config', payload)
return data
}
export async function getStatus(): Promise<ContentModerationRuntimeStatus> {
const { data } = await apiClient.get<ContentModerationRuntimeStatus>('/admin/risk-control/status')
return data
}
export async function testAPIKeys(
payload: TestContentModerationAPIKeysPayload = {}
): Promise<TestContentModerationAPIKeysResponse> {
const { data } = await apiClient.post<TestContentModerationAPIKeysResponse>('/admin/risk-control/api-keys/test', payload)
return data
}
export async function listLogs(
params: ListContentModerationLogsParams = {}
): Promise<ContentModerationLogsResponse> {
const { data } = await apiClient.get<ContentModerationLogsResponse>('/admin/risk-control/logs', {
params,
})
return data
}
export async function unbanUser(userID: number): Promise<ContentModerationUnbanUserResponse> {
const { data } = await apiClient.post<ContentModerationUnbanUserResponse>(
`/admin/risk-control/users/${userID}/unban`
)
return data
}
export async function deleteFlaggedHash(inputHash: string): Promise<DeleteFlaggedHashResponse> {
const { data } = await apiClient.delete<DeleteFlaggedHashResponse>('/admin/risk-control/hashes', {
data: { input_hash: inputHash },
})
return data
}
export async function clearFlaggedHashes(): Promise<ClearFlaggedHashesResponse> {
const { data } = await apiClient.delete<ClearFlaggedHashesResponse>('/admin/risk-control/hashes/all')
return data
}
export const riskControlAPI = {
getConfig,
updateConfig,
getStatus,
testAPIKeys,
listLogs,
unbanUser,
deleteFlaggedHash,
clearFlaggedHashes,
}
export default riskControlAPI
+85
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
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
/**
* 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' | 'suspended'
user_id?: number
group_id?: number
platform?: string
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.post<{ message: string }>(`/admin/subscriptions/${id}/revoke`)
return data
}
/**
* Restore revoked subscription
* @param id - Subscription ID
* @returns Restored subscription
*/
export async function restore(id: number): Promise<UserSubscription> {
const { data } = await apiClient.post<UserSubscription>(`/admin/subscriptions/${id}/restore`)
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,
restore,
resetQuota,
listByGroup,
listByUser
}
export default subscriptionsAPI
+113
View File
@@ -0,0 +1,113 @@
/**
* 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
}
export interface RollbackVersionInfo {
version: string
published_at: string
html_url: string
}
/**
* Get versions available for rollback (up to 3 versions older than current)
*/
export async function getRollbackVersions(): Promise<{ versions: RollbackVersionInfo[] }> {
const { data } = await apiClient.get<{ versions: RollbackVersionInfo[] }>(
'/admin/system/rollback-versions'
)
return data
}
/**
* In-place update/rollback downloads a full release binary from GitHub, which
* can take several minutes on slow links. The global 30s axios timeout would
* abort the request mid-download (#4504), so these calls wait as long as the
* backend allows (15 minutes server-side).
*/
const UPDATE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000
/**
* Perform system update
* Downloads and applies the latest version
*/
export async function performUpdate(): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>('/admin/system/update', undefined, {
timeout: UPDATE_REQUEST_TIMEOUT_MS
})
return data
}
/**
* Rollback to a previous version
* @param version - Target version (e.g. "0.1.146"); omit to restore the local backup binary
*/
export async function rollback(version?: string): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>(
'/admin/system/rollback',
version ? { version } : undefined,
{ timeout: UPDATE_REQUEST_TIMEOUT_MS }
)
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,
getRollbackVersions,
rollback,
restartService
}
export default systemAPI
@@ -0,0 +1,98 @@
/**
* Admin TLS Fingerprint Profile API endpoints
* Handles TLS fingerprint profile CRUD for administrators
*/
import { apiClient } from '../client'
/**
* TLS fingerprint profile interface
*/
export interface TLSFingerprintProfile {
id: number
name: string
description: string | null
enable_grease: boolean
cipher_suites: number[]
curves: number[]
point_formats: number[]
signature_algorithms: number[]
alpn_protocols: string[]
supported_versions: number[]
key_share_groups: number[]
psk_modes: number[]
extensions: number[]
created_at: string
updated_at: string
}
/**
* Create profile request
*/
export interface CreateProfileRequest {
name: string
description?: string | null
enable_grease?: boolean
cipher_suites?: number[]
curves?: number[]
point_formats?: number[]
signature_algorithms?: number[]
alpn_protocols?: string[]
supported_versions?: number[]
key_share_groups?: number[]
psk_modes?: number[]
extensions?: number[]
}
/**
* Update profile request
*/
export interface UpdateProfileRequest {
name?: string
description?: string | null
enable_grease?: boolean
cipher_suites?: number[]
curves?: number[]
point_formats?: number[]
signature_algorithms?: number[]
alpn_protocols?: string[]
supported_versions?: number[]
key_share_groups?: number[]
psk_modes?: number[]
extensions?: number[]
}
export async function list(): Promise<TLSFingerprintProfile[]> {
const { data } = await apiClient.get<TLSFingerprintProfile[]>('/admin/tls-fingerprint-profiles')
return data
}
export async function getById(id: number): Promise<TLSFingerprintProfile> {
const { data } = await apiClient.get<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`)
return data
}
export async function create(profileData: CreateProfileRequest): Promise<TLSFingerprintProfile> {
const { data } = await apiClient.post<TLSFingerprintProfile>('/admin/tls-fingerprint-profiles', profileData)
return data
}
export async function update(id: number, updates: UpdateProfileRequest): Promise<TLSFingerprintProfile> {
const { data } = await apiClient.put<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`, updates)
return data
}
export async function deleteProfile(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/tls-fingerprint-profiles/${id}`)
return data
}
export const tlsFingerprintProfileAPI = {
list,
getById,
create,
update,
delete: deleteProfile
}
export default tlsFingerprintProfileAPI
+219
View File
@@ -0,0 +1,219 @@
/**
* 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_cache_creation_tokens: number
total_cache_read_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
deleted: boolean
}
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
billing_mode?: string
upstream_model_mismatch?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
error_phase?: string | null
error_category?: string | null
status_code?: number | null
}
// ==================== 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
upstream_model_mismatch?: boolean
period?: string
start_date?: string
end_date?: string
timezone?: string
nocache?: number
}): 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
+131
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
+422
View File
@@ -0,0 +1,422 @@
/**
* Admin Users API endpoints
* Handles user management for administrators
*/
import { apiClient } from '../client'
import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types'
export interface AdminBindAuthIdentityChannelRequest {
channel: string
channel_app_id: string
channel_subject: string
metadata?: Record<string, unknown> | null
}
export interface AdminBindAuthIdentityRequest {
provider_type: string
provider_key: string
provider_subject: string
issuer?: string | null
metadata?: Record<string, unknown> | null
channel?: AdminBindAuthIdentityChannelRequest
}
export interface AdminBoundAuthIdentityChannel {
channel: string
channel_app_id: string
channel_subject: string
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
}
export interface AdminBoundAuthIdentity {
user_id: number
provider_type: string
provider_key: string
provider_subject: string
verified_at?: string | null
issuer?: string | null
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
channel?: AdminBoundAuthIdentityChannel | null
}
export interface BatchUpdateUserLimitsRequest {
user_ids: number[]
all?: boolean
concurrency?: number
rpm_limit?: number
}
export interface BatchUpdateUserLimitsResponse {
affected: number
}
/**
* 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
group_name?: string // fuzzy filter by allowed group name
api_key_group_id?: number // filter users by the group their API keys are bound to
attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
},
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,
group_name: filters?.group_name,
api_key_group_id: filters?.api_key_group_id,
include_subscriptions: filters?.include_subscriptions,
sort_by: filters?.sort_by,
sort_order: filters?.sort_order
}
// 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
* @param includeDeleted - Whether to include soft-deleted users
* @returns User details
*/
export async function getById(id: number, includeDeleted = false): Promise<AdminUser> {
const url = includeDeleted ? `/admin/users/${id}?include_deleted=true` : `/admin/users/${id}`
const { data } = await apiClient.get<AdminUser>(url)
return data
}
/**
* Create new user
* @param userData - User data (email, password, etc.)
* @returns Created user
*/
export async function create(userData: {
email: string
password: string
username?: string
notes?: string
role?: 'admin' | 'user'
balance?: number
concurrency?: number
rpm_limit?: 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 })
}
/** Overwrite concurrency and/or RPM limits for multiple users in one request. */
export async function batchUpdateLimits(
request: BatchUpdateUserLimitsRequest
): Promise<BatchUpdateUserLimitsResponse> {
const { data } = await apiClient.post<BatchUpdateUserLimitsResponse>(
'/admin/users/batch-limits',
request
)
return data
}
/**
* 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, affiliate_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
}
/**
* Replace user's exclusive group
* @param userId - User ID
* @param oldGroupId - Current group ID to replace
* @param newGroupId - New group ID to replace with
* @returns Number of migrated keys
*/
export async function replaceGroup(
userId: number,
oldGroupId: number,
newGroupId: number
): Promise<{ migrated_keys: number }> {
const { data } = await apiClient.post<{ migrated_keys: number }>(
`/admin/users/${userId}/replace-group`,
{ old_group_id: oldGroupId, new_group_id: newGroupId }
)
return data
}
export async function bindUserAuthIdentity(
userId: number,
input: AdminBindAuthIdentityRequest
): Promise<AdminBoundAuthIdentity> {
const { data } = await apiClient.post<AdminBoundAuthIdentity>(
`/admin/users/${userId}/auth-identities`,
input
)
return data
}
/**
* Platform quota types
*/
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly'
export interface PlatformQuotaItem {
platform: PlatformQuotaPlatform
daily_limit_usd: number | null
weekly_limit_usd: number | null
monthly_limit_usd: number | null
daily_usage_usd: number
weekly_usage_usd: number
monthly_usage_usd: number
daily_window_start?: string | null
weekly_window_start?: string | null
monthly_window_start?: string | null
daily_window_resets_at?: string | null
weekly_window_resets_at?: string | null
monthly_window_resets_at?: string | null
}
export interface PlatformQuotaUpdateItem {
platform: PlatformQuotaPlatform
daily_limit_usd: number | null
weekly_limit_usd: number | null
monthly_limit_usd: number | null
}
export interface PlatformQuotasResponse {
platform_quotas: PlatformQuotaItem[]
}
/**
* Get user's platform quotas
*/
export async function getPlatformQuotas(id: number): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.get<PlatformQuotasResponse>(
`/admin/users/${id}/platform-quotas`
)
return data
}
/**
* Replace user's platform quotas (全量替换)
*/
export async function updatePlatformQuotas(
id: number,
quotas: PlatformQuotaUpdateItem[]
): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.put<PlatformQuotasResponse>(
`/admin/users/${id}/platform-quotas`,
{ quotas }
)
return data
}
/**
* Reset a single (platform, window) usage immediately
*/
export async function resetPlatformQuotaWindow(
id: number,
platform: PlatformQuotaPlatform,
window: PlatformQuotaWindow
): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.post<PlatformQuotasResponse>(
`/admin/users/${id}/platform-quotas/reset`,
{ platform, window }
)
return data
}
export const usersAPI = {
list,
getById,
create,
update,
delete: deleteUser,
updateBalance,
updateConcurrency,
batchUpdateLimits,
toggleStatus,
getUserApiKeys,
getUserUsageStats,
getUserBalanceHistory,
replaceGroup,
bindUserAuthIdentity,
getPlatformQuotas,
updatePlatformQuotas,
resetPlatformQuotaWindow,
}
export default usersAPI
+78
View File
@@ -0,0 +1,78 @@
export const ADMIN_UI_REQUEST_HEADER = 'X-Admin-UI-Request'
export const USER_UI_REQUEST_HEADER = 'X-User-UI-Request'
function isAdminPath(path: string): boolean {
return (
path === '/admin' ||
path.startsWith('/admin/') ||
path === '/api/v1/admin' ||
path.startsWith('/api/v1/admin/')
)
}
function requestPath(rawURL: string): string {
const value = rawURL.trim()
if (!value) return ''
try {
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
return new URL(value, origin).pathname
} catch {
return value.split(/[?#]/, 1)[0]
}
}
/** Normalize Axios relative paths and absolute API paths to a comparable form. */
function normalizeAPIPath(path: string): string {
const raw = requestPath(path)
if (!raw) return ''
if (raw === '/api/v1' || raw.startsWith('/api/v1/')) {
return raw.slice('/api/v1'.length) || '/'
}
if (raw.startsWith('/')) {
return raw
}
return `/${raw}`
}
/**
* User-facing web APIs that may emit Server-Timing when ENABLE_SERVER_TIMING is on.
* Mirrors backend isUserTimingPath allowlist (excluding public payment surfaces).
*/
export function isUserTimingAPIPath(requestURL: string): boolean {
const path = normalizeAPIPath(requestURL)
if (!path) return false
if (
path === '/auth/me' ||
path === '/auth/revoke-all-sessions' ||
path === '/auth/oauth/bind-token'
) {
return true
}
if (path === '/user' || path.startsWith('/user/')) return true
if (path === '/keys' || path.startsWith('/keys/')) return true
if (path === '/groups/available' || path === '/groups/rates') return true
if (path === '/channels/available') return true
if (path === '/usage' || path.startsWith('/usage/')) return true
if (path === '/announcements' || path.startsWith('/announcements/')) return true
if (path === '/redeem' || path.startsWith('/redeem/')) return true
if (path === '/subscriptions' || path.startsWith('/subscriptions/')) return true
if (path === '/channel-monitors' || path.startsWith('/channel-monitors/')) return true
if (path.startsWith('/payment/')) {
if (path.startsWith('/payment/public') || path.startsWith('/payment/webhook')) {
return false
}
return true
}
return false
}
export function shouldMarkAdminUIRequest(requestURL: string, pagePath?: string): boolean {
const currentPath =
pagePath ?? (typeof window !== 'undefined' ? window.location.pathname : '')
return isAdminPath(requestPath(requestURL)) || isAdminPath(currentPath)
}
export function shouldMarkUserUIRequest(requestURL: string): boolean {
return isUserTimingAPIPath(requestURL)
}
+26
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
+719
View File
@@ -0,0 +1,719 @@
/**
* Authentication API endpoints
* Handles user login, registration, and logout operations
*/
import { apiClient } from './client'
import { refreshAuthTokens, type RefreshTokenResponse } from './tokenRefresh'
export type { RefreshTokenResponse } from './tokenRefresh'
import type {
LoginRequest,
RegisterRequest,
AuthResponse,
CurrentUserResponse,
SendVerifyCodeRequest,
SendVerifyCodeResponse,
PublicSettings,
ActionCaptchaRequestProof,
TotpLoginResponse,
TotpLogin2FARequest
} from '@/types'
/**
* Login response type - can be either full auth or 2FA required
*/
export type LoginResponse = AuthResponse | TotpLoginResponse
export type OAuthLoginProvider =
| 'github'
| 'google'
| 'linuxdo'
| 'dingtalk'
| 'wechat'
| 'oidc'
export interface OAuthLoginStart {
provider: OAuthLoginProvider
params: Record<string, string>
}
export interface OAuthLoginStartResponse {
authorize_url: string
}
export function buildOAuthLoginStartURL(request: OAuthLoginStart): string {
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const query = new URLSearchParams(request.params).toString()
const path = `${normalized}/auth/oauth/${request.provider}/start`
return query ? `${path}?${query}` : path
}
export async function startOAuthLogin(
request: OAuthLoginStart,
proof: ActionCaptchaRequestProof
): Promise<OAuthLoginStartResponse> {
const { data } = await apiClient.post<OAuthLoginStartResponse>(
`/auth/oauth/${request.provider}/start`,
proof,
{ params: request.params }
)
return data
}
/**
* 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 OAuthTokenResponse {
access_token: string
refresh_token?: string
expires_in?: number
token_type?: string
}
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
auth_result?: string
redirect?: string
error?: string
requires_2fa?: boolean
temp_token?: string
user_email_masked?: string
adoption_required?: boolean
suggested_display_name?: string
suggested_avatar_url?: string
}
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {
auth_result?: string
}
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
auth_result?: string
provider?: string
redirect?: string
}
export type OAuthCompletionKind = 'login' | 'bind'
export interface OAuthAdoptionDecision {
adoptDisplayName?: boolean
adoptAvatar?: boolean
}
function serializeOAuthAdoptionDecision(
decision?: OAuthAdoptionDecision
): Record<string, boolean> {
const payload: Record<string, boolean> = {}
if (typeof decision?.adoptDisplayName === 'boolean') {
payload.adopt_display_name = decision.adoptDisplayName
}
if (typeof decision?.adoptAvatar === 'boolean') {
payload.adopt_avatar = decision.adoptAvatar
}
return payload
}
export function isOAuthLoginCompletion(
completion: Partial<OAuthTokenResponse>
): completion is OAuthTokenResponse {
return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0
}
export function getOAuthCompletionKind(
completion: Partial<OAuthTokenResponse>
): OAuthCompletionKind {
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
}
export function getPendingOAuthBindLoginKind(
completion: PendingOAuthBindLoginResponse
): OAuthCompletionKind {
return getOAuthCompletionKind(completion)
}
export function isPendingOAuthCreateAccountRequired(
completion: Pick<PendingOAuthBindLoginResponse, 'error'>
): boolean {
return completion.error === 'invitation_required'
}
export function hasPendingOAuthSuggestedProfile(
completion: Pick<
PendingOAuthBindLoginResponse,
'suggested_display_name' | 'suggested_avatar_url'
>
): boolean {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
}
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
if (tokens.refresh_token) {
setRefreshToken(tokens.refresh_token)
}
if (tokens.expires_in) {
setTokenExpiresAt(tokens.expires_in)
}
}
export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
if (!getAuthToken()) {
return
}
await apiClient.post('/auth/oauth/bind-token')
}
/**
* Refresh the access token using the refresh token
* @returns New token pair
*/
export async function refreshToken(): Promise<RefreshTokenResponse> {
return refreshAuthTokens()
}
/**
* 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
}
export type WeChatOAuthMode = 'open' | 'mp'
export type WeChatOAuthUnavailableReason =
| 'not_configured'
| 'capability_unknown'
| 'external_browser_required'
| 'wechat_browser_required'
| 'native_app_required'
export interface ResolvedWeChatOAuthStart {
mode: WeChatOAuthMode | null
openEnabled: boolean
mpEnabled: boolean
mobileEnabled: boolean
isWeChatBrowser: boolean
unavailableReason: WeChatOAuthUnavailableReason | null
}
export type WeChatOAuthPublicSettings = {
wechat_oauth_enabled?: boolean
wechat_oauth_open_enabled?: boolean
wechat_oauth_mp_enabled?: boolean
wechat_oauth_mobile_enabled?: boolean
}
export function isWeChatWebOAuthEnabled(
settings: WeChatOAuthPublicSettings | null | undefined,
): boolean {
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
const hasExplicitCapabilities =
typeof settings?.wechat_oauth_open_enabled === 'boolean' ||
typeof settings?.wechat_oauth_mp_enabled === 'boolean'
if (!hasExplicitCapabilities) {
return legacyEnabled
}
return settings?.wechat_oauth_open_enabled === true || settings?.wechat_oauth_mp_enabled === true
}
export function hasExplicitWeChatOAuthCapabilities(
settings: WeChatOAuthPublicSettings | null | undefined,
): settings is WeChatOAuthPublicSettings & {
wechat_oauth_open_enabled: boolean
wechat_oauth_mp_enabled: boolean
} {
return typeof settings?.wechat_oauth_open_enabled === 'boolean'
&& typeof settings?.wechat_oauth_mp_enabled === 'boolean'
}
export function resolveWeChatOAuthStart(
settings: WeChatOAuthPublicSettings | null | undefined,
userAgent?: string
): ResolvedWeChatOAuthStart {
const normalizedUserAgent = (userAgent
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
?? '').trim()
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
const openEnabled = typeof settings?.wechat_oauth_open_enabled === 'boolean'
? settings.wechat_oauth_open_enabled
: legacyEnabled
const mpEnabled = typeof settings?.wechat_oauth_mp_enabled === 'boolean'
? settings.wechat_oauth_mp_enabled
: legacyEnabled
const mobileEnabled = typeof settings?.wechat_oauth_mobile_enabled === 'boolean'
? settings.wechat_oauth_mobile_enabled
: false
if (isWeChatBrowser) {
if (mpEnabled) {
return { mode: 'mp', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
}
if (openEnabled) {
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'external_browser_required' }
}
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
}
if (openEnabled) {
return { mode: 'open', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
}
if (mpEnabled) {
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'wechat_browser_required' }
}
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
}
export function resolveWeChatOAuthStartStrict(
settings: WeChatOAuthPublicSettings | null | undefined,
userAgent?: string,
): ResolvedWeChatOAuthStart {
const normalizedUserAgent = (userAgent
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
?? '').trim()
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
if (!hasExplicitWeChatOAuthCapabilities(settings)) {
return {
mode: null,
openEnabled: false,
mpEnabled: false,
mobileEnabled: false,
isWeChatBrowser,
unavailableReason: 'capability_unknown',
}
}
return resolveWeChatOAuthStart(settings, normalizedUserAgent)
}
/**
* 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
}
export async function sendPendingOAuthVerifyCode(
request: SendVerifyCodeRequest
): Promise<PendingOAuthSendVerifyCodeResponse> {
const { data } = await apiClient.post<PendingOAuthSendVerifyCodeResponse>(
'/auth/oauth/pending/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
tencent_captcha_ticket?: string
tencent_captcha_randstr?: 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 invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export async function completeLinuxDoOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
}
/**
* Complete OIDC OAuth registration by supplying an invitation code
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export async function completeOIDCOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
}
export async function completeWeChatOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
}
async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat' | 'dingtalk',
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
const normalizedAffiliateCode = affiliateCode?.trim()
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/auth/oauth/${provider}/complete-registration`,
{
invitation_code: invitationCode,
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
...serializeOAuthAdoptionDecision(decision)
}
)
return data
}
export async function createPendingLinuxDoOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
}
export async function createPendingOIDCOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
}
export async function createPendingWeChatOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
}
export async function createPendingDingTalkOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode)
}
export async function completePendingOAuthBindLogin(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthBindLoginResponse> {
const { data } = await apiClient.post<PendingOAuthBindLoginResponse>(
'/auth/oauth/pending/exchange',
serializeOAuthAdoptionDecision(decision)
)
return data
}
export async function exchangePendingOAuthCompletion(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthExchangeResponse> {
return completePendingOAuthBindLogin(decision)
}
export const authAPI = {
login,
login2FA,
isTotp2FARequired,
register,
getCurrentUser,
logout,
isAuthenticated,
setAuthToken,
setRefreshToken,
setTokenExpiresAt,
getAuthToken,
getRefreshToken,
getTokenExpiresAt,
clearAuthToken,
getPublicSettings,
sendVerifyCode,
sendPendingOAuthVerifyCode,
validatePromoCode,
validateInvitationCode,
forgotPassword,
resetPassword,
refreshToken,
revokeAllSessions,
getPendingOAuthBindLoginKind,
isPendingOAuthCreateAccountRequired,
hasPendingOAuthSuggestedProfile,
completePendingOAuthBindLogin,
createPendingLinuxDoOAuthAccount,
createPendingOIDCOAuthAccount,
createPendingWeChatOAuthAccount,
exchangePendingOAuthCompletion,
completeLinuxDoOAuthRegistration,
completeOIDCOAuthRegistration,
completeWeChatOAuthRegistration,
createPendingDingTalkOAuthAccount
}
export default authAPI
+245
View File
@@ -0,0 +1,245 @@
import { buildGatewayUrl } from './client'
export type BatchImageStatus =
| 'queued'
| 'running'
| 'indexing'
| 'processing_results'
| 'settling'
| 'completed'
| 'failed'
| 'cancelled'
| 'output_deleted'
| string
export interface BatchImageSubmitItem {
custom_id: string
prompt: string
output_count?: number
reference_images?: BatchImageReferenceImage[]
}
export interface BatchImageReferenceImage {
id?: string
type?: string
mime_type: string
data?: string
file_uri?: string
}
export interface BatchImageSubmitRequest {
model: string
task_name?: string
parent_batch_id?: string
provider?: '' | 'gemini_api' | 'vertex' | string
image_size?: '1K' | '2K' | '4K' | string
response_mime_type?: string
aspect_ratio?: string
items: BatchImageSubmitItem[]
metadata?: Record<string, string>
}
export interface BatchImageJob {
id: string
object: string
task_name: string
parent_batch_id?: string | null
status: BatchImageStatus
model: string
provider: string
item_count: number
success_count: number
fail_count: number
estimated_cost: number
hold_amount: number
actual_cost: number | null
created_at: number
submitted_at: number | null
settled_at: number | null
downloaded_at?: number | null
output_deleted_at?: number | null
}
export interface BatchImageItem {
batch_id?: string
source_task_name?: string
custom_id: string
status: string
prompt_preview?: string | null
mime_type: string | null
file_extension: string | null
image_count: number
error?: {
code: string
message: string
source?: 'provider' | 'system' | string
} | null
}
export interface BatchImageItemsResponse {
object: string
data: BatchImageItem[]
has_more: boolean
}
export interface BatchImageJobsResponse {
object: string
data: BatchImageJob[]
has_more: boolean
}
export interface BatchImageModel {
id: string
object: string
provider: string
}
export interface BatchImageModelsResponse {
object: string
data: BatchImageModel[]
}
export interface BatchImageJobsListOptions {
limit?: number
cursor?: string
status?: string
taskName?: string
downloaded?: '' | 'true' | 'false' | string
from?: string
to?: string
}
async function parseBatchImageError(response: Response): Promise<Error> {
try {
const body = await response.json()
const message = body?.error?.message || body?.message || response.statusText
const error = new Error(message)
;(error as any).code = body?.error?.code || response.status
;(error as any).status = response.status
;(error as any).requestId = response.headers.get('X-Request-Id') || ''
return error
} catch {
const error = new Error(response.statusText || `HTTP ${response.status}`)
;(error as any).code = response.status
;(error as any).status = response.status
;(error as any).requestId = response.headers.get('X-Request-Id') || ''
return error
}
}
function authHeaders(apiKey: string, extra?: HeadersInit): HeadersInit {
return {
Authorization: `Bearer ${apiKey}`,
...extra,
}
}
export async function submitBatchImageJob(
apiKey: string,
payload: BatchImageSubmitRequest,
idempotencyKey: string,
): Promise<BatchImageJob> {
const response = await fetch(buildGatewayUrl('/v1/images/batches'), {
method: 'POST',
headers: authHeaders(apiKey, {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
}),
body: JSON.stringify(payload),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.json()
}
export async function getBatchImageJob(apiKey: string, batchId: string): Promise<BatchImageJob> {
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}`), {
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.json()
}
export async function listBatchImageJobs(apiKey: string, options: number | BatchImageJobsListOptions = 20): Promise<BatchImageJobsResponse> {
const params = new URLSearchParams()
if (typeof options === 'number') {
params.set('limit', String(options))
} else {
params.set('limit', String(options.limit || 20))
if (options.cursor) params.set('cursor', options.cursor)
if (options.status) params.set('status', options.status)
if (options.taskName) params.set('task_name', options.taskName)
if (options.downloaded) params.set('downloaded', options.downloaded)
if (options.from) params.set('from', options.from)
if (options.to) params.set('to', options.to)
}
const response = await fetch(buildGatewayUrl(`/v1/images/batches?${params.toString()}`), {
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.json()
}
export async function listBatchImageModels(apiKey: string): Promise<BatchImageModelsResponse> {
const response = await fetch(buildGatewayUrl('/v1/images/batches/models'), {
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.json()
}
export async function listBatchImageItems(
apiKey: string,
batchId: string,
status = '',
): Promise<BatchImageItemsResponse> {
const query = status ? `?status=${encodeURIComponent(status)}` : ''
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/items${query}`), {
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.json()
}
export async function cancelBatchImageJob(apiKey: string, batchId: string): Promise<BatchImageJob> {
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/cancel`), {
method: 'POST',
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.json()
}
export async function downloadBatchImageZip(apiKey: string, batchId: string): Promise<Blob> {
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/download`), {
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.blob()
}
export async function getBatchImageItemContent(apiKey: string, batchId: string, customId: string, imageIndex = 0): Promise<Blob> {
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/items/${encodeURIComponent(customId)}/content?image_index=${encodeURIComponent(String(imageIndex))}`), {
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
return response.blob()
}
export async function deleteBatchImageJobRecord(apiKey: string, batchId: string): Promise<void> {
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}`), {
method: 'DELETE',
headers: authHeaders(apiKey),
})
if (!response.ok) throw await parseBatchImageError(response)
}
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
+88
View File
@@ -0,0 +1,88 @@
/**
* User-facing Channel Monitor API endpoints
* Read-only views for end users to inspect channel availability/status.
*/
import { apiClient } from './client'
import type { MonitorQuotaSnapshot, Provider, MonitorStatus } from './admin/channelMonitor'
export type { Provider, MonitorStatus } from './admin/channelMonitor'
export interface UserMonitorExtraModel {
model: string
status: MonitorStatus
latency_ms: number | null
}
export interface MonitorTimelinePoint {
status: MonitorStatus
latency_ms: number | null
ping_latency_ms: number | null
checked_at: string
}
export interface UserMonitorView {
id: number
name: string
provider: Provider
group_name: string
primary_model: string
primary_status: MonitorStatus
primary_latency_ms: number | null
primary_ping_latency_ms: number | null
availability_7d: number
extra_models: UserMonitorExtraModel[]
timeline: MonitorTimelinePoint[]
/**
* 主模型最近配额快照。仅当系统开启 channel_monitor_show_quota 时
* 服务端才会下发(关闭时服务端已剥离,前端 flag 仅作纵深防御)。
*/
latest_quota?: MonitorQuotaSnapshot | null
}
export interface UserMonitorListResponse {
items: UserMonitorView[]
}
export interface UserMonitorModelDetail {
model: string
latest_status: MonitorStatus
latest_latency_ms: number | null
availability_7d: number
availability_15d: number
availability_30d: number
avg_latency_7d_ms: number | null
}
export interface UserMonitorDetail {
id: number
name: string
provider: Provider
group_name: string
models: UserMonitorModelDetail[]
}
/**
* List all monitor views available to the current user.
*/
export async function list(options?: { signal?: AbortSignal }): Promise<UserMonitorListResponse> {
const { data } = await apiClient.get<UserMonitorListResponse>('/channel-monitors', {
signal: options?.signal,
})
return data
}
/**
* Get detailed status (multi-window availability + latency) for a single monitor.
*/
export async function status(id: number): Promise<UserMonitorDetail> {
const { data } = await apiClient.get<UserMonitorDetail>(`/channel-monitors/${id}/status`)
return data
}
export const channelMonitorUserAPI = {
list,
status,
}
export default channelMonitorUserAPI
+271
View File
@@ -0,0 +1,271 @@
import { apiClient } from './client'
export type MonitorRange = '90m' | '24h' | '7d' | '30d'
export type HealthState = 'unknown' | 'healthy' | 'warning' | 'critical'
/** Fine-grained score band for multi-stop green→yellow→red gradients (score0..score10). */
export type HealthScoreBand =
| 'unknown'
| 'score0'
| 'score1'
| 'score2'
| 'score3'
| 'score4'
| 'score5'
| 'score6'
| 'score7'
| 'score8'
| 'score9'
| 'score10'
export type MonitorMatrixGroupBy = 'platform' | 'platform_group' | 'platform_model' | 'platform_group_model'
export interface MonitorFilter {
range: MonitorRange
platforms: string[]
groupIds: number[]
models: string[]
}
export interface LatencyMetric {
sample_count: number
p50_ms: number | null
p90_ms?: number | null
p95_ms: number | null
avg_ms: number | null
}
export interface MonitorMetric {
success_requests: number
error_requests: number
request_count: number
token_count: number
rpm: number
tpm: number
error_rate: number
cache_rate: number
cache_rate_numerator: number
cache_rate_denominator: number
ttft: LatencyMetric
duration: LatencyMetric
upstream_affected_requests?: number
upstream_attempt_count?: number
}
export interface MonitorHealth {
overall: HealthState
error_rate: HealthState
ttft: HealthState
cache?: HealthState
/** 0100 blended score when samples are sufficient. */
score?: number | null
error_rate_score?: number | null
ttft_score?: number | null
cache_score?: number | null
minimum_sample: number
thresholds?: {
minimum_sample?: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate?: number
critical_cache_rate?: number
error_weight: number
ttft_weight: number
cache_weight?: number
}
}
/** First-upgrade historical fill for 90m/24h/7d/30d; omitted when complete. */
export interface MonitorBootstrap {
active: boolean
progress_percent: number
covered_from?: string
target_start?: string
}
export interface MonitorCoverage {
requested_start: string
/** Exclusive upper bound of the UI-selected range (filter end). */
requested_end?: string
coverage_start: string
data_through: string
computed_at: string
aggregation_lag_seconds: number
coverage_complete: boolean
bucket_seconds: number
/** Present while initial aggregation has not covered the 30d product window. */
bootstrap?: MonitorBootstrap | null
}
export interface MonitorConfig {
version: number
enabled: boolean
refresh_interval_seconds: 60 | 300
platforms: Array<{ platform: string; enabled: boolean; models: string[] }>
group_ids: number[]
health_thresholds: {
minimum_sample: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate: number
critical_cache_rate: number
error_weight: number
ttft_weight: number
cache_weight: number
}
/** Categories excluded from error_rate / health; still listed in error breakdown. */
ignored_error_categories?: string[]
}
/** Ordered taxonomy mirrored from backend ChannelMonitorV2ErrorCategories. */
export const MONITOR_ERROR_CATEGORIES = [
'content_policy',
'authentication',
'context_limit',
'invalid_request',
'model_unsupported',
'group_access',
'quota_or_balance',
'account_pool_unavailable',
'rate_or_capacity',
'timeout',
'transport_or_stream',
'upstream_forbidden',
'not_found',
'client_cancelled',
'upstream_5xx',
'internal',
'other',
] as const
export type MonitorErrorCategory = (typeof MONITOR_ERROR_CATEGORIES)[number]
export interface MonitorSnapshot {
config: MonitorConfig
coverage: MonitorCoverage
metrics: MonitorMetric
health: MonitorHealth
trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }>
}
export interface MonitorMatrixBucket {
bucket_start: string
metrics: MonitorMetric
health: MonitorHealth
}
export interface MonitorMatrixRow {
platform: string
group_id?: number
group_name?: string
model?: string
metrics: MonitorMetric
health: MonitorHealth
buckets: MonitorMatrixBucket[]
}
export interface MonitorMatrixResponse {
coverage: MonitorCoverage
group_by: MonitorMatrixGroupBy
items: MonitorMatrixRow[]
}
export interface MonitorDimensions {
platforms: Array<{ value: string; label: string; request_count: number }>
groups: Array<{ id: number; name: string; platform?: string; request_count: number }>
models: Array<{ value: string; label: string; platform?: string; request_count: number }>
}
export interface MonitorModelRow { platform: string; model: string; metrics: MonitorMetric; health: MonitorHealth }
export interface MonitorErrorRow {
category: string
count: number
rate: number
details?: Array<{
platform?: string
model?: string
error_type?: string
status_code?: number
upstream_status_code?: number
message?: string
count: number
}>
/** true when category is in config.ignored_error_categories */
ignored?: boolean
}
export interface MonitorUserRow {
user_id?: number
rank: number
email?: string
username?: string
display_label: string
is_self: boolean
can_drilldown: boolean
metrics: MonitorMetric
}
function params(filter: MonitorFilter) {
return {
range: filter.range,
platform: filter.platforms.length ? filter.platforms : undefined,
group_id: filter.groupIds.length ? filter.groupIds : undefined,
model: filter.models.length ? filter.models : undefined,
}
}
export function repeatedArrayParamsSerializer(values: Record<string, unknown>): string {
const search = new URLSearchParams()
for (const [key, rawValue] of Object.entries(values)) {
if (rawValue == null || rawValue === '') continue
const entries = Array.isArray(rawValue) ? rawValue : [rawValue]
for (const value of entries) {
if (value != null && value !== '') search.append(key, String(value))
}
}
return search.toString()
}
const requestConfig = (filter: MonitorFilter, signal?: AbortSignal, extraParams: Record<string, unknown> = {}) => ({
params: { ...params(filter), ...extraParams },
paramsSerializer: { serialize: repeatedArrayParamsSerializer },
signal,
})
function base(admin: boolean) { return admin ? '/admin/channel-monitor-v2' : '/channel-monitor-v2' }
export async function getDimensions(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorDimensions>(`${base(admin)}/dimensions`, requestConfig(filter, signal))
return data
}
export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorSnapshot>(`${base(admin)}/snapshot`, requestConfig(filter, signal))
return data
}
export async function getMatrix(filter: MonitorFilter, groupBy: MonitorMatrixGroupBy, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorMatrixResponse>(`${base(admin)}/matrix`, requestConfig(filter, signal, { group_by: groupBy }))
return data
}
export async function getModels(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorModelRow[] }>(`${base(admin)}/models`, requestConfig(filter, signal))
return data
}
export async function getErrors(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorErrorRow[] }>(`${base(admin)}/errors`, requestConfig(filter, signal))
return data
}
export async function getUsers(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorUserRow[] }>(`${base(admin)}/users`, requestConfig(filter, signal))
return data
}
export async function getConfig() {
const { data } = await apiClient.get<MonitorConfig>('/admin/channel-monitor-v2/config')
return data
}
export async function updateConfig(config: MonitorConfig) {
const { data } = await apiClient.put<MonitorConfig>('/admin/channel-monitor-v2/config', config)
return data
}
+81
View File
@@ -0,0 +1,81 @@
/**
* User Channels API endpoints (non-admin)
* 用户侧「可用渠道」聚合查询:渠道 + 用户可访问的分组 + 支持模型(含定价)。
*/
import { apiClient } from './client'
import type { BillingMode } from '@/constants/channel'
export interface UserAvailableGroup {
id: number
name: string
platform: string
/** 'standard' | 'subscription' — 订阅分组视觉加深,和 API 密钥页保持一致。 */
subscription_type: string
/** 分组默认倍率。用户专属倍率(若有)通过 /groups/rates 获取后在前端 join。 */
rate_multiplier: number
peak_rate_enabled: boolean
peak_start: string
peak_end: string
peak_rate_multiplier: number
/** true = 专属分组(小范围授权);false = 公开分组。 */
is_exclusive: boolean
}
export interface UserPricingInterval {
min_tokens: number
max_tokens: number | null
tier_label?: string
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
per_request_price: number | null
}
export interface UserSupportedModelPricing {
billing_mode: BillingMode
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
image_input_price: number | null
image_output_price: number | null
per_request_price: number | null
intervals: UserPricingInterval[]
}
export interface UserSupportedModel {
name: string
platform: string
pricing: UserSupportedModelPricing | null
}
/**
* 渠道下单个平台的子视图:用户可访问的分组 + 该平台支持的模型。
* 后端把一个渠道按平台聚合成 sections,前端可以把渠道名作为 row-group
* 一次渲染,后面按 sections 顺序用 rowspan 铺开。
*/
export interface UserChannelPlatformSection {
platform: string
groups: UserAvailableGroup[]
supported_models: UserSupportedModel[]
}
export interface UserAvailableChannel {
name: string
description: string
platforms: UserChannelPlatformSection[]
}
/** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */
export async function getAvailable(options?: { signal?: AbortSignal }): Promise<UserAvailableChannel[]> {
const { data } = await apiClient.get<UserAvailableChannel[]>('/channels/available', {
signal: options?.signal
})
return data
}
export const userChannelsAPI = { getAvailable }
export default userChannelsAPI
+265
View File
@@ -0,0 +1,265 @@
/**
* 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'
import {
ADMIN_UI_REQUEST_HEADER,
USER_UI_REQUEST_HEADER,
shouldMarkAdminUIRequest,
shouldMarkUserUIRequest,
} from './adminUIRequest'
import { refreshAuthTokens } from './tokenRefresh'
import { getAPIBaseURL } from './url'
export { buildApiUrl, buildGatewayUrl } from './url'
// ==================== Axios Instance Configuration ====================
export const apiClient: AxiosInstance = axios.create({
baseURL: getAPIBaseURL(),
withCredentials: true,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// ==================== 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()
}
if (config.headers) {
const requestURL = String(config.url || '')
if (shouldMarkAdminUIRequest(requestURL)) {
config.headers[ADMIN_UI_REQUEST_HEADER] = '1'
}
if (shouldMarkUserUIRequest(requestURL)) {
config.headers[USER_UI_REQUEST_HEADER] = '1'
}
}
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
const resp = apiResponse as unknown as Record<string, unknown>
return Promise.reject({
status: response.status,
code: apiResponse.code,
message: apiResponse.message || 'Unknown error',
reason: resp.reason,
metadata: resp.metadata,
})
}
}
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
})
}
if (status === 423 && apiData.code === 'ADMIN_COMPLIANCE_ACK_REQUIRED') {
try {
window.dispatchEvent(new CustomEvent('admin-compliance-required', {
detail: apiData.metadata || {}
}))
} catch {
// ignore event failures
}
return Promise.reject({
status,
code: apiData.code,
message: apiData.message || error.message,
metadata: apiData.metadata,
})
}
// 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) {
const refreshSessionUser = localStorage.getItem('auth_user')
originalRequest._retry = true
try {
const headers = originalRequest.headers as Record<string, unknown> | undefined
const authHeader = headers?.Authorization ?? headers?.authorization
const failedAccessToken =
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: null
const tokens = await refreshAuthTokens({ failedAccessToken })
// Retry the original request with the refreshed token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${tokens.access_token}`
}
return apiClient(originalRequest)
} catch {
// A stale request must never destroy a session that was logged out or replaced while
// its refresh was in flight (for example, when another tab signs in as another user).
const sessionChanged =
localStorage.getItem('refresh_token') !== refreshToken ||
localStorage.getItem('auth_user') !== refreshSessionUser
if (sessionChanged) {
return Promise.reject({
status: 401,
code: 'AUTH_SESSION_CHANGED',
message: 'Authentication session changed while refreshing.'
})
}
// 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,
reason: apiData.reason,
error: apiData.error,
message: apiData.message || apiData.detail || error.message,
metadata: apiData.metadata,
})
}
// Network error
return Promise.reject({
status: 0,
message: 'Network error. Please check your connection.'
})
}
)
export default apiClient
+35
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
+30
View File
@@ -0,0 +1,30 @@
/**
* 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 { paymentAPI } from './payment'
export { userGroupsAPI } from './groups'
export { userChannelsAPI } from './channels'
export * as batchImageAPI from './batchImage'
export { totpAPI } from './totp'
export { passkeyAPI, type PasskeyCredentialSummary } from './passkey'
export { default as announcementsAPI } from './announcements'
export { channelMonitorUserAPI } from './channelMonitor'
// Admin APIs
export { adminAPI } from './admin'
// Default export
export { default } from './client'
+143
View File
@@ -0,0 +1,143 @@
/**
* 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
sort_by?: string
sort_order?: 'asc' | 'desc'
},
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
+65
View File
@@ -0,0 +1,65 @@
/**
* Model Plaza API(公开端点,可匿名访问)
* 以分组为中心的模型价目:分组信息 + 模型渠道定价 + LiteLLM 官方参考价。
* 带 token 请求时后端会额外返回专属分组与用户专属倍率。
*/
import { apiClient } from './client'
import type { UserSupportedModelPricing } from './channels'
/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */
export interface PlazaOfficialPricing {
input_price: number | null
output_price: number | null
/** 5m 缓存写入(= LiteLLM cache_creation)。 */
cache_write_price: number | null
/** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */
cache_write_1h_price?: number | null
cache_read_price: number | null
}
export interface PlazaModel {
name: string
platform: string
pricing: UserSupportedModelPricing | null
official_pricing: PlazaOfficialPricing | null
}
export interface ModelPlazaGroup {
id: number
name: string
description: string
platform: string
/** 'standard' | 'subscription' */
subscription_type: string
rate_multiplier: number
/** 登录且管理员为该用户配了专属倍率时返回;生效倍率 = user_rate ?? rate_multiplier。 */
user_rate_multiplier?: number
peak_rate_enabled: boolean
peak_start: string
peak_end: string
peak_rate_multiplier: number
is_exclusive: boolean
/** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */
image_rate_independent: boolean
image_rate_multiplier: number
models: PlazaModel[]
}
export interface ModelPlazaResponse {
/** 管理员配置的全局价格说明(Markdown)。 */
description: string
groups: ModelPlazaGroup[]
}
/** 获取模型广场数据。开关未启用时后端返回 404。 */
export async function getModelPlaza(options?: { signal?: AbortSignal }): Promise<ModelPlazaResponse> {
const { data } = await apiClient.get<ModelPlazaResponse>('/model-plaza', {
signal: options?.signal
})
return data
}
export const modelPlazaAPI = { getModelPlaza }
export default modelPlazaAPI
+168
View File
@@ -0,0 +1,168 @@
import { apiClient } from './client'
import type { ActionCaptchaRequestProof, AuthResponse } from '@/types'
export interface PasskeyCredentialSummary {
id: number
name: string
created_at: string
last_used_at?: string
backup: boolean
}
interface CeremonyOptionsResponse {
session_token: string
options: {
publicKey: Record<string, unknown>
}
}
function requirePasskeySupport(): void {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error('Passkeys are not supported by this browser')
}
}
function base64URLToBuffer(value: string): ArrayBuffer {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const binary = atob(padded)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return bytes.buffer
}
function bufferToBase64URL(value: ArrayBuffer | null): string | null {
if (value === null) return null
const bytes = new Uint8Array(value)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function creationOptionsFromJSON(
value: Record<string, unknown>
): PublicKeyCredentialCreationOptions {
const options = { ...value } as Record<string, unknown>
options.challenge = base64URLToBuffer(String(options.challenge))
const user = { ...(options.user as Record<string, unknown>) }
user.id = base64URLToBuffer(String(user.id))
options.user = user
if (Array.isArray(options.excludeCredentials)) {
options.excludeCredentials = options.excludeCredentials.map((descriptor) => ({
...(descriptor as Record<string, unknown>),
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
}))
}
return options as unknown as PublicKeyCredentialCreationOptions
}
function requestOptionsFromJSON(
value: Record<string, unknown>
): PublicKeyCredentialRequestOptions {
const options = { ...value } as Record<string, unknown>
options.challenge = base64URLToBuffer(String(options.challenge))
if (Array.isArray(options.allowCredentials)) {
options.allowCredentials = options.allowCredentials.map((descriptor) => ({
...(descriptor as Record<string, unknown>),
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
}))
}
return options as unknown as PublicKeyCredentialRequestOptions
}
function serializeRegistrationCredential(credential: PublicKeyCredential): Record<string, unknown> {
const response = credential.response as AuthenticatorAttestationResponse
return {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
attestationObject: bufferToBase64URL(response.attestationObject),
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
transports: typeof response.getTransports === 'function' ? response.getTransports() : []
}
}
}
function serializeAssertionCredential(credential: PublicKeyCredential): Record<string, unknown> {
const response = credential.response as AuthenticatorAssertionResponse
return {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
authenticatorData: bufferToBase64URL(response.authenticatorData),
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
signature: bufferToBase64URL(response.signature),
userHandle: bufferToBase64URL(response.userHandle)
}
}
}
async function login(proof?: ActionCaptchaRequestProof): Promise<AuthResponse> {
requirePasskeySupport()
const { data: begin } = proof
? await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin', proof)
: await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin')
const credential = await navigator.credentials.get({
publicKey: requestOptionsFromJSON(begin.options.publicKey)
})
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('Passkey sign-in was cancelled')
}
const { data } = await apiClient.post<AuthResponse>('/auth/passkey/login/finish', {
session_token: begin.session_token,
credential: serializeAssertionCredential(credential)
})
return data
}
async function register(name: string, password: string): Promise<PasskeyCredentialSummary> {
requirePasskeySupport()
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
'/user/passkeys/register/begin',
{ password }
)
const credential = await navigator.credentials.create({
publicKey: creationOptionsFromJSON(begin.options.publicKey)
})
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('Passkey creation was cancelled')
}
const { data } = await apiClient.post<PasskeyCredentialSummary>(
'/user/passkeys/register/finish',
{
session_token: begin.session_token,
name,
credential: serializeRegistrationCredential(credential)
}
)
return data
}
async function list(): Promise<PasskeyCredentialSummary[]> {
const { data } = await apiClient.get<PasskeyCredentialSummary[]>('/user/passkeys')
return data
}
async function rename(id: number, name: string): Promise<void> {
await apiClient.patch(`/user/passkeys/${id}`, { name })
}
async function remove(id: number, password: string): Promise<void> {
await apiClient.delete(`/user/passkeys/${id}`, { data: { password } })
}
export const passkeyAPI = {
isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials),
login,
register,
list,
rename,
remove
}
+91
View File
@@ -0,0 +1,91 @@
/**
* User Payment API endpoints
* Handles payment operations for regular users
*/
import { apiClient } from './client'
import type {
PaymentConfig,
SubscriptionPlan,
MethodLimitsResponse,
CheckoutInfoResponse,
CreateOrderRequest,
CreateOrderResult,
PaymentOrder
} from '@/types/payment'
import type { BasePaginationResponse } from '@/types'
export interface PublicOrderVerifyResult {
out_trade_no: string
status: string
paid: boolean
created_at: string
expires_at: string
}
export const paymentAPI = {
/** Get payment configuration (enabled types, limits, etc.) */
getConfig() {
return apiClient.get<PaymentConfig>('/payment/config')
},
/** Get available subscription plans */
getPlans() {
return apiClient.get<SubscriptionPlan[]>('/payment/plans')
},
/** Get all checkout page data in a single call */
getCheckoutInfo() {
return apiClient.get<CheckoutInfoResponse>('/payment/checkout-info')
},
/** Get payment method limits and fee rates */
getLimits() {
return apiClient.get<MethodLimitsResponse>('/payment/limits')
},
/** Create a new payment order */
createOrder(data: CreateOrderRequest) {
return apiClient.post<CreateOrderResult>('/payment/orders', data)
},
/** Get current user's orders */
getMyOrders(params?: { page?: number; page_size?: number; status?: string }) {
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/payment/orders/my', { params })
},
/** Get a specific order by ID */
getOrder(id: number) {
return apiClient.get<PaymentOrder>(`/payment/orders/${id}`)
},
/** Cancel a pending order */
cancelOrder(id: number) {
return apiClient.post(`/payment/orders/${id}/cancel`)
},
/** Verify order payment status with upstream provider */
verifyOrder(outTradeNo: string) {
return apiClient.post<PaymentOrder>('/payment/orders/verify', { out_trade_no: outTradeNo })
},
/** Legacy-compatible public order lookup by out_trade_no */
verifyOrderPublic(outTradeNo: string) {
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
},
/** Resolve an order from a signed resume token without auth */
resolveOrderPublicByResumeToken(resumeToken: string) {
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/resolve', { resume_token: resumeToken })
},
/** Request a refund for a completed order */
requestRefund(id: number, data: { reason: string }) {
return apiClient.post(`/payment/orders/${id}/refund-request`, data)
},
/** Get provider instance IDs that allow user refund */
getRefundEligibleProviders() {
return apiClient.get<{ provider_instance_ids: string[] }>('/payment/orders/refund-eligible-providers')
}
}
+67
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
+90
View File
@@ -0,0 +1,90 @@
/**
* Setup API endpoints
*/
import axios from 'axios'
import { buildGatewayUrl } from './url'
// Create a separate client for setup endpoints (not under /api/v1)
const setupClient = axios.create({
baseURL: buildGatewayUrl('/').replace(/\/+$/, ''),
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
username: string
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
}
+76
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
}
+241
View File
@@ -0,0 +1,241 @@
import axios from 'axios'
import type { ApiResponse } from '@/types'
import { getAPIBaseURL } from './url'
const AUTH_TOKEN_KEY = 'auth_token'
const AUTH_USER_KEY = 'auth_user'
const REFRESH_TOKEN_KEY = 'refresh_token'
const TOKEN_EXPIRES_AT_KEY = 'token_expires_at'
const TOKEN_REFRESH_LOCK_NAME = 'sub2api-auth-token-refresh'
const TOKEN_REFRESH_TIMEOUT_MS = 30_000
const TOKEN_REFRESH_BUFFER_MS = 120_000
const PEER_REFRESH_WAIT_MS = 1_000
const PEER_REFRESH_GRACE_MS = 1_000
const PEER_REFRESH_POLL_MS = 25
export interface RefreshTokenResponse {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
export interface RefreshAuthTokensOptions {
/** Access token attached to the request that received a 401 response. */
failedAccessToken?: string | null
}
interface AuthSnapshot {
accessToken: string | null
refreshToken: string
expiresAt: number
userID: number | null
}
let inFlightRefresh: Promise<RefreshTokenResponse> | null = null
function getStoredUserID(): number | null {
const rawUser = localStorage.getItem(AUTH_USER_KEY)
if (!rawUser) {
return null
}
try {
const id = Number((JSON.parse(rawUser) as { id?: unknown }).id)
return Number.isFinite(id) && id > 0 ? id : null
} catch {
return null
}
}
function readAuthSnapshot(): AuthSnapshot {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
if (!refreshToken) {
throw new Error('No refresh token available')
}
return {
accessToken: localStorage.getItem(AUTH_TOKEN_KEY),
refreshToken,
expiresAt: Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)),
userID: getStoredUserID()
}
}
function readStoredTokenPair(snapshot: AuthSnapshot): RefreshTokenResponse | null {
const accessToken = localStorage.getItem(AUTH_TOKEN_KEY)
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY))
if (
!accessToken ||
!refreshToken ||
!Number.isFinite(expiresAt) ||
expiresAt <= Date.now() ||
getStoredUserID() !== snapshot.userID
) {
return null
}
return {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)),
token_type: 'Bearer'
}
}
function readPeerRefreshResult(
snapshot: AuthSnapshot,
failedAccessToken?: string | null
): RefreshTokenResponse | null {
const storedPair = readStoredTokenPair(snapshot)
if (!storedPair) {
return null
}
if (storedPair.refresh_token !== snapshot.refreshToken) {
return storedPair
}
if (
failedAccessToken &&
snapshot.accessToken !== failedAccessToken &&
storedPair.access_token === snapshot.accessToken
) {
return storedPair
}
if (!failedAccessToken) {
const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY))
if (
expiresAt === snapshot.expiresAt &&
storedPair.access_token === snapshot.accessToken &&
expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS
) {
return storedPair
}
}
return null
}
async function waitForPeerRefresh(
snapshot: AuthSnapshot,
failedAccessToken?: string | null,
deadline = Date.now() + PEER_REFRESH_WAIT_MS
): Promise<RefreshTokenResponse | null> {
while (Date.now() < deadline) {
const peerResult = readPeerRefreshResult(snapshot, failedAccessToken)
if (peerResult) {
return peerResult
}
await new Promise((resolve) => window.setTimeout(resolve, PEER_REFRESH_POLL_MS))
}
return readPeerRefreshResult(snapshot, failedAccessToken)
}
function persistTokenPair(tokens: RefreshTokenResponse): void {
localStorage.setItem(AUTH_TOKEN_KEY, tokens.access_token)
localStorage.setItem(TOKEN_EXPIRES_AT_KEY, String(Date.now() + tokens.expires_in * 1000))
// The rotating refresh token is written last so other tabs can treat its change as a commit marker.
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
}
async function requestTokenPair(
snapshot: AuthSnapshot,
failedAccessToken?: string | null,
mayHaveUncoordinatedPeer = false
): Promise<RefreshTokenResponse> {
// If this request loses a one-time-token race, the winning peer can legitimately take as long
// as our own HTTP timeout to publish its replacement token. Keep the recovery window tied to
// that timeout instead of an arbitrary short delay.
const peerRefreshDeadline = Date.now() + TOKEN_REFRESH_TIMEOUT_MS + PEER_REFRESH_GRACE_MS
try {
const response = await axios.post<ApiResponse<RefreshTokenResponse>>(
`${getAPIBaseURL()}/auth/refresh`,
{ refresh_token: snapshot.refreshToken },
{ headers: { 'Content-Type': 'application/json' }, timeout: TOKEN_REFRESH_TIMEOUT_MS }
)
const payload = response.data
if (payload.code !== 0 || !payload.data) {
throw new Error(payload.message || 'Token refresh failed')
}
if (
localStorage.getItem(REFRESH_TOKEN_KEY) !== snapshot.refreshToken ||
getStoredUserID() !== snapshot.userID
) {
const peerResult = readPeerRefreshResult(snapshot, failedAccessToken)
if (peerResult) {
return peerResult
}
throw new Error('Session changed during token refresh')
}
persistTokenPair(payload.data)
return payload.data
} catch (error) {
// A peer tab may have rotated the one-time refresh token while this request was in flight.
// A 4xx response can arrive quickly while the winning peer's response is still in flight, so
// wait through the shared request deadline before treating the session as expired. Transient
// non-4xx failures retain the short reconciliation window.
const responseStatus = (error as { response?: { status?: unknown } }).response?.status
const isTokenRejection =
typeof responseStatus === 'number' && responseStatus >= 400 && responseStatus < 500
const peerResult = await waitForPeerRefresh(
snapshot,
failedAccessToken,
isTokenRejection && mayHaveUncoordinatedPeer
? peerRefreshDeadline
: Date.now() + PEER_REFRESH_WAIT_MS
)
if (peerResult) {
return peerResult
}
throw error
}
}
async function runRefresh(options: RefreshAuthTokensOptions): Promise<RefreshTokenResponse> {
const snapshot = readAuthSnapshot()
const refresh = async (mayHaveUncoordinatedPeer = false): Promise<RefreshTokenResponse> => {
const peerResult = readPeerRefreshResult(snapshot, options.failedAccessToken)
if (peerResult) {
return peerResult
}
return requestTokenPair(snapshot, options.failedAccessToken, mayHaveUncoordinatedPeer)
}
if (typeof navigator !== 'undefined' && navigator.locks) {
return navigator.locks.request(TOKEN_REFRESH_LOCK_NAME, () => refresh(false))
}
return refresh(true)
}
/**
* Refresh and persist the browser session.
*
* Calls in the same document share one promise. Web Locks serialize refreshes across tabs, while
* the token snapshot check adopts a peer's newly rotated token instead of logging the user out.
*/
export function refreshAuthTokens(
options: RefreshAuthTokensOptions = {}
): Promise<RefreshTokenResponse> {
if (inFlightRefresh) {
return inFlightRefresh
}
const pending = runRefresh(options)
inFlightRefresh = pending
const clearPending = (): void => {
if (inFlightRefresh === pending) {
inFlightRefresh = null
}
}
void pending.then(clearPending, clearPending)
return pending
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 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
}
/**
* Step-up verification response
*/
export interface TotpStepUpResponse {
verified: boolean
expires_in: number
}
/**
* Verify a TOTP code to grant the current session a short-lived step-up
* (sudo) window for sensitive operations (account export, DB backup download...).
* @param code - 6-digit TOTP code
*/
export async function stepUp(code: string): Promise<TotpStepUpResponse> {
const { data } = await apiClient.post<TotpStepUpResponse>('/user/totp/step-up', { code })
return data
}
export const totpAPI = {
getStatus,
getVerificationMethod,
sendVerifyCode,
initiateSetup,
enable,
disable,
stepUp
}
export default totpAPI
+43
View File
@@ -0,0 +1,43 @@
const DEFAULT_API_BASE_URL = '/api/v1'
const API_BASE_URL = normalizeAPIBaseURL(import.meta.env.VITE_API_BASE_URL)
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`
}
function normalizeAPIBaseURL(value: unknown): string {
const raw = String(value || DEFAULT_API_BASE_URL).trim() || DEFAULT_API_BASE_URL
const withoutTrailingSlash = raw.replace(/\/+$/, '')
if (/^[a-z][a-z\d+.-]*:\/\//i.test(withoutTrailingSlash) || withoutTrailingSlash.startsWith('//')) {
return withoutTrailingSlash
}
return normalizePath(withoutTrailingSlash)
}
export function getAPIBaseURL(): string {
return API_BASE_URL
}
export function buildApiUrl(path: string): string {
const base = getAPIBaseURL().replace(/\/+$/, '')
let suffix = normalizePath(path)
if (suffix === DEFAULT_API_BASE_URL) {
suffix = ''
} else if (suffix.startsWith(`${DEFAULT_API_BASE_URL}/`)) {
suffix = suffix.slice(DEFAULT_API_BASE_URL.length)
}
return `${base}${suffix}`
}
export function buildGatewayUrl(path: string): string {
const suffix = normalizePath(path)
try {
const origin =
typeof window === 'undefined'
? new URL(getAPIBaseURL()).origin
: new URL(getAPIBaseURL(), window.location.origin).origin
return `${origin}${suffix}`
} catch {
return suffix
}
}
+390
View File
@@ -0,0 +1,390 @@
/**
* Usage tracking API endpoints
* Handles usage logs and statistics retrieval
*/
import { apiClient } from './client'
import type {
UsageLog,
UsageQueryParams,
UsageStatsResponse,
PaginatedResponse,
TrendDataPoint,
ModelStat,
GroupStat,
UsageRequestType,
UserErrorRequest,
UserErrorRequestDetail,
UserErrorListParams
} from '@/types'
// ==================== Dashboard Types ====================
export interface PlatformDashboardStats {
platform: string
total_requests: number
total_tokens: number
total_actual_cost: number
today_requests: number
today_tokens: number
today_actual_cost: number
}
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数
by_platform?: PlatformDashboardStats[]
}
export interface TrendParams {
start_date?: string
end_date?: string
granularity?: 'day' | 'hour'
api_key_id?: number
model?: string
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}
export interface TrendResponse {
trend: TrendDataPoint[]
start_date: string
end_date: string
granularity: string
}
export interface ModelStatsResponse {
models: ModelStat[]
start_date: string
end_date: string
}
export interface ApiKeyDailyUsagePoint {
date: string
requests: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
total_tokens: number
cost: number
actual_cost: number
}
export interface ApiKeyDailyUsageResponse {
items: ApiKeyDailyUsagePoint[]
days: number
start_date: string
end_date: string
}
export interface UsageDashboardSnapshotV2Params extends TrendParams {
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
}
export interface UsageDashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
}
/**
* 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 & { sort_by?: string; sort_order?: 'asc' | 'desc' },
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(
paramsOrPeriod: (UsageQueryParams & { period?: string; timezone?: string }) | string = 'today',
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = typeof paramsOrPeriod === 'string'
? { period: paramsOrPeriod }
: { ...paramsOrPeriod }
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
api_key_id?: number
model?: string
model_source?: 'requested'
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}): Promise<ModelStatsResponse> {
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params })
return data
}
/**
* Get daily usage details for one API key owned by the current user.
* @param apiKeyId - API key ID
* @param days - Number of days to include (1-90)
* @returns Daily usage detail rows
*/
export async function getMyApiKeyDailyUsage(
apiKeyId: number,
days: number = 30
): Promise<ApiKeyDailyUsageResponse> {
const { data } = await apiClient.get<ApiKeyDailyUsageResponse>(
`/user/api-keys/${apiKeyId}/usage/daily`,
{ params: { days } }
)
return data
}
export async function getDashboardSnapshotV2(
params?: UsageDashboardSnapshotV2Params
): Promise<UsageDashboardSnapshotV2Response> {
const { data } = await apiClient.get<UsageDashboardSnapshotV2Response>(
'/usage/dashboard/snapshot-v2',
{ 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 async function listMyErrorRequests(
params: UserErrorListParams
): Promise<PaginatedResponse<UserErrorRequest>> {
const { data } = await apiClient.get<PaginatedResponse<UserErrorRequest>>('/usage/errors', {
params
})
return data
}
export async function getMyErrorDetail(id: number): Promise<UserErrorRequestDetail> {
const { data } = await apiClient.get<UserErrorRequestDetail>(`/usage/errors/${id}`)
return data
}
export const usageAPI = {
list,
query,
getStats,
getStatsByDateRange,
getByDateRange,
getById,
// Dashboard
getDashboardStats,
getDashboardTrend,
getDashboardModels,
getMyApiKeyDailyUsage,
getDashboardSnapshotV2,
getDashboardApiKeysUsage,
// Error requests
listMyErrorRequests,
getMyErrorDetail
}
export default usageAPI
+215
View File
@@ -0,0 +1,215 @@
/**
* User API endpoints
* Handles user profile management and password changes
*/
import { apiClient } from './client'
import {
resolveWeChatOAuthStartStrict,
prepareOAuthBindAccessTokenCookie,
type WeChatOAuthPublicSettings,
} from './auth'
import type {
User,
ChangePasswordRequest,
NotifyEmailEntry,
UserAuthProvider,
UserAffiliateDetail,
AffiliateTransferResponse,
PlatformQuotasResponse,
} 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
avatar_url?: string | null
balance_notify_enabled?: boolean
balance_notify_threshold?: number | null
balance_notify_extra_emails?: NotifyEmailEntry[]
}): 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
}
/**
* Send verification code for adding a notify email
* @param email - Email address to verify
*/
export async function sendNotifyEmailCode(email: string): Promise<void> {
await apiClient.post('/user/notify-email/send-code', { email })
}
/**
* Verify and add a notify email
* @param email - Email address to add
* @param code - Verification code
*/
export async function verifyNotifyEmail(email: string, code: string): Promise<void> {
await apiClient.post('/user/notify-email/verify', { email, code })
}
/**
* Remove a notify email
* @param email - Email address to remove
*/
export async function removeNotifyEmail(email: string): Promise<void> {
await apiClient.delete('/user/notify-email', { data: { email } })
}
/**
* Toggle a notify email's disabled state
* @param email - Email address (empty string for primary email placeholder)
* @param disabled - Whether to disable the email
*/
export async function toggleNotifyEmail(email: string, disabled: boolean): Promise<User> {
const { data } = await apiClient.put<User>('/user/notify-email/toggle', { email, disabled })
return data
}
export async function sendEmailBindingCode(email: string): Promise<void> {
await apiClient.post('/user/account-bindings/email/send-code', { email })
}
export async function bindEmailIdentity(payload: {
email: string
verify_code: string
password: string
}): Promise<User> {
const { data } = await apiClient.post<User>('/user/account-bindings/email', payload)
return data
}
export async function unbindAuthIdentity(provider: BindableOAuthProvider): Promise<User> {
const { data } = await apiClient.delete<User>(`/user/account-bindings/${provider}`)
return data
}
export type BindableOAuthProvider = Exclude<UserAuthProvider, 'email'>
interface BuildOAuthBindingStartURLOptions {
redirectTo?: string
wechatOAuthSettings?: WeChatOAuthPublicSettings | null
}
export function resolveWeChatOAuthMode(): 'open' | 'mp' {
if (typeof navigator === 'undefined') {
return 'open'
}
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
}
function resolveWeChatOAuthBindingMode(
settings?: WeChatOAuthPublicSettings | null
): 'open' | 'mp' | null {
if (settings) {
return resolveWeChatOAuthStartStrict(settings).mode
}
return resolveWeChatOAuthMode()
}
export function buildOAuthBindingStartURL(
provider: BindableOAuthProvider,
options: BuildOAuthBindingStartURLOptions = {}
): string | null {
const redirectTo = options.redirectTo?.trim() || '/profile'
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const params = new URLSearchParams({
redirect: redirectTo,
intent: 'bind_current_user'
})
if (provider === 'wechat') {
const mode = resolveWeChatOAuthBindingMode(options.wechatOAuthSettings)
if (!mode) {
return null
}
params.set('mode', mode)
}
return `${normalized}/auth/oauth/${provider}/bind/start?${params.toString()}`
}
export async function startOAuthBinding(
provider: BindableOAuthProvider,
options: BuildOAuthBindingStartURLOptions = {}
): Promise<void> {
if (typeof window === 'undefined') {
return
}
const startURL = buildOAuthBindingStartURL(provider, options)
if (!startURL) {
return
}
await prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
}
export async function getAffiliateDetail(): Promise<UserAffiliateDetail> {
const { data } = await apiClient.get<UserAffiliateDetail>('/user/aff')
return data
}
export async function transferAffiliateQuota(): Promise<AffiliateTransferResponse> {
const { data } = await apiClient.post<AffiliateTransferResponse>('/user/aff/transfer')
return data
}
/**
* 获取当前用户的平台限额 + 用量。
*/
export async function getMyPlatformQuotas(): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.get<PlatformQuotasResponse>('/user/platform-quotas')
return data
}
export const userAPI = {
getProfile,
updateProfile,
changePassword,
sendNotifyEmailCode,
verifyNotifyEmail,
removeNotifyEmail,
toggleNotifyEmail,
sendEmailBindingCode,
bindEmailIdentity,
unbindAuthIdentity,
buildOAuthBindingStartURL,
startOAuthBinding,
getAffiliateDetail,
transferAffiliateQuota,
getMyPlatformQuotas,
}
export default userAPI
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48.1 32.3" role="img" aria-label="Airwallex">
<defs>
<linearGradient id="airwallex-mark" x1="0" y1="2" x2="48" y2="30" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FF4F42"/>
<stop offset="1" stop-color="#FF8E3C"/>
</linearGradient>
</defs>
<path
fill="url(#airwallex-mark)"
d="M312.76 380.53a6 6 0 0 1 1.42 6.42l-3.18 8.58a6.89 6.89 0 0 1-5 4.47 6.8 6.8 0 0 1-1.3.13 6.58 6.58 0 0 1-5.08-2.4l-19-22.69a.42.42 0 0 0-.71.12l-6.17 16.67a.42.42 0 0 0 .55.54l7.57-3.09a3.34 3.34 0 0 1 4.44 2.08 3.47 3.47 0 0 1-2 4.24l-9.89 4a5.93 5.93 0 0 1-7.88-7.56l7.29-19.68a6.84 6.84 0 0 1 11.68-2l10.88 13 10-4.08a5.84 5.84 0 0 1 6.38 1.24ZM307 387.07a.42.42 0 0 0-.55-.54l-5.53 2.26 3.32 4a.42.42 0 0 0 .71-.13Z"
transform="translate(-266.13 -367.85)"
/>
</svg>

After

Width:  |  Height:  |  Size: 858 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563099286" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1395" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M902.095 652.871l-250.96-84.392s19.287-28.87 39.874-85.472c20.59-56.606 23.539-87.689 23.539-87.689l-162.454-1.339v-55.487l196.739-1.387v-39.227H552.055v-89.29h-96.358v89.294H272.133v39.227l183.564-1.304v59.513h-147.24v31.079h303.064s-3.337 25.223-14.955 56.606c-11.615 31.38-23.58 58.862-23.58 58.862s-142.3-49.804-217.285-49.804c-74.985 0-166.182 30.123-175.024 117.55-8.8 87.383 42.481 134.716 114.728 152.139 72.256 17.513 138.962-0.173 197.04-28.607 58.087-28.391 115.081-92.933 115.081-92.933l292.486 142.041c-11.932 69.3-72.067 119.914-142.387 119.844H266.37c-79.714 0.078-144.392-64.483-144.466-144.194V266.374c-0.074-79.72 64.493-144.399 144.205-144.47h491.519c79.714-0.073 144.396 64.49 144.466 144.203v386.764z m-365.76-48.895s-91.302 115.262-198.879 115.262c-107.623 0-130.218-54.767-130.218-94.155 0-39.34 22.373-82.144 113.943-88.333 91.519-6.18 215.2 67.226 215.2 67.226h-0.047z" fill="#02A9F1" p-id="1396"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563141699" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2705" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M647.3728 287.744c-2.048-4.9152-5.7344-9.0112-11.0592-12.0832-5.3248-3.072-12.9024-4.7104-22.9376-4.7104h-221.184c-0.2048 0-0.2048 0.2048-0.2048 0.2048V305.152h260.096c-1.024-6.7584-2.6624-12.4928-4.7104-17.408zM634.0608 400.9984c6.9632-3.2768 11.264-8.192 13.1072-14.5408l5.12-14.7456h-260.096c-0.2048 0-0.2048 0.2048-0.2048 0.2048V405.504c0 0.2048 0.2048 0.2048 0.2048 0.2048h220.9792c6.9632 0.4096 13.9264-1.4336 20.8896-4.7104z" fill="#48D8FF" p-id="2706"></path><path d="M512 1.6384C230.1952 1.6384 1.6384 230.1952 1.6384 512S230.1952 1022.3616 512 1022.3616 1022.3616 793.8048 1022.3616 512 793.8048 1.6384 512 1.6384z m289.5872 644.3008c-0.2048 4.3008-1.2288 20.48-3.2768 48.5376s-4.9152 50.7904-8.8064 67.9936c-3.8912 17.408-13.5168 31.3344-29.0816 41.984-15.5648 10.6496-30.72 16.384-45.2608 17.408-12.9024 0.8192-25.1904-1.024-36.2496-5.9392-11.264-4.9152-20.48-10.8544-27.8528-18.2272-7.3728-7.3728-13.7216-16.5888-19.0464-28.0576-5.3248-11.4688-6.9632-18.0224-4.9152-20.0704 2.048-1.8432 4.096-3.2768 6.3488-3.8912 3.072-0.6144 8.3968-0.2048 15.9744 1.6384s13.9264 2.8672 19.2512 3.072c5.7344 0.4096 12.0832 0 18.8416-1.4336 6.7584-1.4336 12.0832-3.6864 16.384-6.9632 4.096-3.2768 7.3728-7.7824 9.8304-13.7216 2.2528-5.9392 3.8912-13.1072 4.7104-21.504 0.8192-8.3968 1.8432-22.3232 3.072-41.984s2.048-32.5632 2.2528-39.1168v-29.2864c0-6.7584-1.024-11.8784-3.2768-15.36-2.2528-3.4816-7.5776-5.12-15.7696-5.12h-2.4576c-22.3232 0-43.8272 8.6016-60.2112 23.9616-7.9872 7.5776-16.1792 17.408-23.9616 29.4912-7.9872 12.0832-15.5648 25.6-22.9376 40.7552l-18.2272 38.7072c-16.5888 33.9968-36.0448 61.2352-58.5728 81.3056-22.528 20.0704-65.1264 30.72-127.5904 31.9488-14.336 0.4096-24.9856-0.4096-32.1536-2.2528-6.9632-2.048-11.264-4.096-12.4928-6.144-1.2288-2.048-1.024-4.7104 0.4096-7.5776 1.024-2.2528 3.072-3.8912 5.9392-4.9152s11.4688-3.2768 25.8048-6.5536 30.5152-11.0592 48.3328-22.9376c17.8176-11.8784 27.2384-20.0704 35.4304-30.1056 8.192-10.0352 20.6848-26.4192 30.72-44.2368 10.0352-17.8176 24.1664-45.056 34.6112-60.6208 10.0352-15.1552 41.984-53.0432 81.5104-60.0064 0.4096 0 0.4096-0.6144 0-0.6144h-75.1616c-9.4208 0-18.8416 1.8432-27.2384 6.144-7.168 3.6864-10.8544 8.8064-27.0336 36.2496-15.9744 26.8288-36.864 51.2-36.864 51.2-20.2752 25.6-36.6592 43.6224-57.344 56.9344-20.6848 13.312-49.5616 19.0464-87.04 16.9984-12.288-0.4096-23.3472-1.8432-33.3824-4.096-9.8304-2.2528-15.1552-4.3008-15.7696-6.144-0.6144-1.8432-0.4096-3.8912 1.024-5.9392 0.8192-1.4336 4.9152-2.8672 12.288-4.7104 7.3728-1.8432 15.36-4.5056 24.1664-7.9872 8.8064-3.6864 40.3456-15.7696 72.9088-48.5376 28.2624-28.2624 50.3808-60.416 63.8976-73.1136 4.096-3.6864 16.384-13.7216 32.5632-17.408h-54.272c-11.4688 0-20.48 3.4816-29.4912 14.1312-15.9744 18.8416-31.1296 31.1296-46.08 36.0448-18.432 6.144-33.9968 9.4208-46.2848 9.8304h-30.5152c-16.7936 0.6144-25.6-0.6144-26.624-4.096-0.8192-3.2768-0.4096-6.144 1.4336-8.3968 1.8432-2.048 6.144-4.3008 12.6976-6.5536s14.336-6.3488 22.7328-12.0832c8.3968-5.7344 15.5648-11.4688 21.504-16.9984 5.9392-5.5296 12.288-12.9024 19.2512-22.1184l21.0944-29.4912c8.192-11.0592 19.456-22.3232 33.5872-33.9968l31.5392-25.6c0.2048-0.2048 0-0.6144-0.2048-0.6144h-69.0176c-0.2048 0-0.2048-0.2048-0.2048-0.2048V201.9328c0-0.2048 0.2048-0.2048 0.2048-0.2048h288.1536c66.3552 0 103.8336 16.9984 112.2304 50.9952s12.6976 63.0784 12.4928 87.6544c-0.2048 23.7568-2.6624 44.032-7.5776 61.0304-4.9152 16.9984-16.5888 33.5872-35.2256 50.176-18.6368 16.5888-46.08 24.7808-81.92 24.7808h-110.7968c-9.8304 0-19.456 2.8672-27.648 7.9872l-25.1904 15.7696c-3.2768 2.2528-6.144 4.5056-8.3968 6.9632h268.9024c22.9376 0 41.1648 1.2288 54.8864 3.4816 13.7216 2.2528 23.9616 7.7824 30.9248 16.384 6.9632 8.6016 11.0592 18.432 12.288 29.4912 1.2288 11.0592 1.8432 24.3712 1.8432 39.936-0.4096 28.672-0.6144 45.056-0.6144 49.5616z" fill="#48D8FF" p-id="2707"></path></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Payment">
<path d="M512 64c247.424 0 448 200.576 448 448S759.424 960 512 960 64 759.424 64 512 264.576 64 512 64Z" fill="#4F46E5"/>
<path d="M307 329c0-39.765 32.235-72 72-72h274c39.765 0 72 32.235 72 72v36H371c-35.346 0-64 28.654-64 64V329Z" fill="#C7D2FE"/>
<path d="M260 413c0-35.346 28.654-64 64-64h392c35.346 0 64 28.654 64 64v258c0 35.346-28.654 64-64 64H324c-35.346 0-64-28.654-64-64V413Z" fill="#FFFFFF"/>
<path d="M636 481c0-30.928 25.072-56 56-56h88v214h-88c-30.928 0-56-25.072-56-56V481Z" fill="#EEF2FF"/>
<path d="M708 494c30.928 0 56 25.072 56 56s-25.072 56-56 56-56-25.072-56-56 25.072-56 56-56Z" fill="#FBBF24"/>
<path d="M348 457c0-17.673 14.327-32 32-32h172c17.673 0 32 14.327 32 32s-14.327 32-32 32H380c-17.673 0-32-14.327-32-32Z" fill="#4F46E5"/>
<path d="M348 557c0-15.464 12.536-28 28-28h152c15.464 0 28 12.536 28 28s-12.536 28-28 28H376c-15.464 0-28-12.536-28-28Z" fill="#A5B4FC"/>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563184449" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3692" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 512m-448 0a448 448 0 1 0 896 0 448 448 0 1 0-896 0Z" fill="#676BE5" p-id="3693"></path><path d="M471.616 417.296c0-20.96 17.488-29.04 45.488-29.04a300.8 300.8 0 0 1 133.36 34.496v-126.224a353.6 353.6 0 0 0-133.264-24.528c-108.8 0-181.2 56.768-181.2 151.696 0 148.4 203.76 124.336 203.76 188.352 0 24.816-21.52 32.8-51.408 32.8a338.368 338.368 0 0 1-146.704-42.768v120.768a372.272 372.272 0 0 0 146.624 30.4c111.472 0 188.256-48 188.256-144.368 0-160-204.88-131.296-204.88-191.632" fill="#FFFFFF" p-id="3694"></path></svg>

After

Width:  |  Height:  |  Size: 859 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563104166" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1545" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M395.846 603.585c-3.921 1.98-7.936 2.925-12.81 2.925-10.9 0-19.791-5.85-24.764-14.625l-2.006-3.864-78.106-167.913c-0.956-1.98-0.956-3.865-0.956-5.845 0-7.83 5.928-13.68 13.863-13.68 2.965 0 5.928 0.944 8.893 2.924l91.965 64.43c6.884 3.864 14.82 6.79 23.708 6.79 4.972 0 9.85-0.945 14.822-2.926L861.71 282.479c-77.149-89.804-204.684-148.384-349.135-148.384-235.371 0-427.242 157.158-427.242 351.294 0 105.368 57.361 201.017 147.323 265.447 6.88 4.905 11.852 13.68 11.852 22.45 0 2.925-0.957 5.85-2.006 8.775-6.881 26.318-18.831 69.334-18.831 71.223-0.958 2.92-2.013 6.79-2.013 10.75 0 7.83 5.929 13.68 13.865 13.68 2.963 0 5.928-0.944 7.935-2.925l92.922-53.674c6.885-3.87 14.82-6.794 22.756-6.794 3.916 0 8.889 0.944 12.81 1.98 43.496 12.644 91.012 19.53 139.48 19.53 235.372 0 427.24-157.158 427.24-351.294 0-58.58-17.78-114.143-48.467-163.003l-491.39 280.07-2.963 1.98z" fill="#09BB07" p-id="1546"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,363 @@
<template>
<div v-if="sceneId && prefix" class="aliyun-captcha-wrapper">
<button
:id="buttonId"
type="button"
class="aliyun-captcha-button"
:class="state === 'verified' ? 'aliyun-captcha-button--verified' : ''"
:disabled="state === 'verified'"
>
<svg
v-if="state === 'verified'"
class="aliyun-captcha-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<svg
v-else
class="aliyun-captcha-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M9.661 2.237a.531.531 0 01.678 0 11.947 11.947 0 007.078 2.749.5.5 0 01.479.425c.069.52.104 1.05.104 1.59 0 5.162-3.26 9.563-7.834 11.256a.48.48 0 01-.332 0C5.26 16.564 2 12.163 2 7c0-.538.035-1.069.104-1.589a.5.5 0 01.48-.425 11.947 11.947 0 007.077-2.75z"
clip-rule="evenodd"
/>
</svg>
<span>{{ buttonText }}</span>
</button>
<div :id="elementId"></div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
interface AliyunCaptchaVerifyResult {
captchaResult: boolean
bizResult?: boolean
}
interface AliyunCaptchaInitOptions {
SceneId: string
prefix: string
mode: 'popup' | 'embed'
element: string
button: string
captchaVerifyCallback: (
captchaVerifyParam: string
) => AliyunCaptchaVerifyResult | Promise<AliyunCaptchaVerifyResult>
onBizResultCallback: (bizResult: boolean) => void
getInstance: (instance: unknown) => void
slideStyle?: { width: number; height: number }
language?: string
}
declare global {
interface Window {
initAliyunCaptcha?: (options: AliyunCaptchaInitOptions) => void
AliyunCaptchaConfig?: { region: string; prefix: string }
}
}
const props = withDefaults(
defineProps<{
sceneId: string
prefix: string
region?: 'cn' | 'sgp'
}>(),
{
region: 'cn'
}
)
const emit = defineEmits<{
(e: 'verify', param: string): void
(e: 'expire'): void
(e: 'error'): void
}>()
const { t, locale } = useI18n()
const uid = Math.random().toString(36).slice(2, 10)
const buttonId = `aliyun-captcha-button-${uid}`
const elementId = `aliyun-captcha-element-${uid}`
// idle: 未验证可点击;verifying: 弹窗已拉起(关闭后回到 idle 可重试);verified: 已通过
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
const buttonText = computed(() => {
switch (state.value) {
case 'verified':
return t('auth.captchaVerified')
case 'verifying':
return t('auth.captchaVerifying')
default:
return t('auth.captchaClickToVerify')
}
})
const SCRIPT_SRC = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
const POPUP_ID = 'aliyunCaptcha-window-popup'
const MASK_ID = 'aliyunCaptcha-mask'
const POPUP_OPEN_TIMEOUT_MS = 8000
const POPUP_WATCH_INTERVAL_MS = 300
// captchaVerifyParam 是一次性参数:verified 后缓存于此,提交失败需 reset 后重新验证
let cachedParam: string | null = null
let pending: { resolve: (value: string | null) => void } | null = null
let popupWatchTimer: number | null = null
let readyPromise: Promise<void> | null = null
const loadScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
// 全局配置必须在脚本加载前就位(region/prefix 全站一致,重复赋值无副作用)
window.AliyunCaptchaConfig = { region: props.region, prefix: props.prefix }
if (window.initAliyunCaptcha) {
resolve()
return
}
const existingScript = document.querySelector<HTMLScriptElement>(
'script[src*="aliyunCaptcha/AliyunCaptcha"]'
)
if (existingScript) {
existingScript.addEventListener('load', () => resolve())
existingScript.addEventListener('error', () =>
reject(new Error('Failed to load Aliyun captcha script'))
)
return
}
const script = document.createElement('script')
script.src = SCRIPT_SRC
script.async = true
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Aliyun captcha script'))
document.head.appendChild(script)
})
}
function initCaptcha(): void {
if (!window.initAliyunCaptcha) {
throw new Error('Aliyun captcha script not ready')
}
window.initAliyunCaptcha({
SceneId: props.sceneId,
prefix: props.prefix,
mode: 'popup',
element: `#${elementId}`,
button: `#${buttonId}`,
// 这里不发业务请求,只把 captchaVerifyParam 当 token 交给页面,随登录/注册等
// 业务请求的 turnstile_token 字段提交,由后端在业务接口内调阿里云校验。
captchaVerifyCallback: (captchaVerifyParam: string) => {
onCaptchaParam(captchaVerifyParam)
return { captchaResult: true }
},
onBizResultCallback: () => {},
getInstance: () => {},
slideStyle: { width: 360, height: 40 },
language: locale.value.toLowerCase().startsWith('zh') ? 'cn' : 'en'
})
}
function ensureReady(): Promise<void> {
if (!readyPromise) {
readyPromise = loadScript().then(() => initCaptcha())
readyPromise.catch(() => {
// 失败后允许下次重试(如网络恢复)
readyPromise = null
})
}
return readyPromise
}
function onCaptchaParam(param: string): void {
stopPopupWatch()
cachedParam = param
state.value = 'verified'
emit('verify', param)
const current = pending
pending = null
current?.resolve(param)
}
function settlePending(value: string | null): void {
const current = pending
pending = null
current?.resolve(value)
}
function isPopupVisible(): boolean {
const popup = document.getElementById(POPUP_ID)
if (!popup) return false
return window.getComputedStyle(popup).display !== 'none'
}
function stopPopupWatch(): void {
if (popupWatchTimer !== null) {
window.clearInterval(popupWatchTimer)
popupWatchTimer = null
}
}
// SDK 没有用户关闭弹窗的回调,靠轮询弹窗可见性兜底:
// 弹窗出现过又消失且未产生 param → 用户主动关闭;迟迟未出现 → 打开失败。
// initAliyunCaptcha 对触发按钮的事件绑定是异步完成的,首次 click 可能落空,
// 因此弹窗出现前每个 tick 重试触发一次。
function startPopupWatch(): void {
stopPopupWatch()
const startedAt = Date.now()
let seen = false
popupWatchTimer = window.setInterval(() => {
if (state.value === 'verified') {
stopPopupWatch()
return
}
if (isPopupVisible()) {
seen = true
return
}
if (seen || Date.now() - startedAt > POPUP_OPEN_TIMEOUT_MS) {
stopPopupWatch()
state.value = 'idle'
settlePending(null)
return
}
document.getElementById(buttonId)?.click()
}, POPUP_WATCH_INTERVAL_MS)
}
// 用户点击与程序化触发共用:置 verifying 并启动弹窗监视(幂等,重试 click 不重置计时)
function handleTriggerClick(): void {
if (state.value === 'verified') {
return
}
state.value = 'verifying'
if (popupWatchTimer === null) {
startPopupWatch()
}
}
// 程序化触发验证(OAuth 启动、passkey、未预验证时的表单提交兜底):
// 已通过预验证则直接复用缓存的 captchaVerifyParam;否则弹出验证码等待结果。
// 用户关闭/未能弹出 resolve null;脚本加载失败 reject。
async function verify(): Promise<string | null> {
if (state.value === 'verified' && cachedParam) {
return cachedParam
}
settlePending(null)
await ensureReady()
return new Promise<string | null>((resolve) => {
pending = { resolve }
document.getElementById(buttonId)?.click()
})
}
// 重置为未验证态;captchaVerifyParam 是一次性参数,服务端校验失败后需用户重新验证
function reset(): void {
stopPopupWatch()
settlePending(null)
cachedParam = null
state.value = 'idle'
}
defineExpose({ verify, reset })
onMounted(async () => {
if (!props.sceneId || !props.prefix) {
return
}
document.getElementById(buttonId)?.addEventListener('click', handleTriggerClick)
try {
await ensureReady()
} catch (error) {
console.error('Failed to initialize Aliyun captcha:', error)
emit('error')
}
})
onUnmounted(() => {
document.getElementById(buttonId)?.removeEventListener('click', handleTriggerClick)
stopPopupWatch()
settlePending(null)
// SDK 不会自清理弹窗 DOM,残留会导致下次挂载时回调重复触发
document.getElementById(MASK_ID)?.remove()
document.getElementById(POPUP_ID)?.remove()
})
</script>
<style scoped>
.aliyun-captcha-wrapper {
width: 100%;
}
.aliyun-captcha-button {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgb(209 213 219);
background-color: rgb(249 250 251);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: rgb(55 65 81);
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.aliyun-captcha-button:hover:not(:disabled) {
border-color: rgb(156 163 175);
background-color: rgb(243 244 246);
}
.aliyun-captcha-button--verified {
border-color: rgb(34 197 94);
background-color: rgb(240 253 244);
color: rgb(21 128 61);
}
:root.dark .aliyun-captcha-button,
.dark .aliyun-captcha-button {
border-color: rgb(55 65 81);
background-color: rgb(31 41 55);
color: rgb(209 213 219);
}
:root.dark .aliyun-captcha-button:hover:not(:disabled),
.dark .aliyun-captcha-button:hover:not(:disabled) {
border-color: rgb(75 85 99);
background-color: rgb(55 65 81);
}
:root.dark .aliyun-captcha-button--verified,
.dark .aliyun-captcha-button--verified {
border-color: rgb(34 197 94);
background-color: rgb(20 83 45 / 0.3);
color: rgb(134 239 172);
}
.aliyun-captcha-icon {
height: 1rem;
width: 1rem;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,97 @@
<template>
<TurnstileWidget
v-if="turnstileEnabled && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="(token) => emit('verify', token, '')"
@expire="emit('expire')"
@error="emit('error')"
/>
<TencentCaptchaGate
v-else-if="tencentEnabled && tencentAppId"
ref="tencentRef"
:app-id="tencentAppId"
:region="tencentRegion"
/>
<AliyunCaptchaWidget
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
ref="aliyunRef"
:scene-id="aliyunSceneId"
:prefix="aliyunPrefix"
:region="aliyunRegion === 'sgp' ? 'sgp' : 'cn'"
@verify="(param: string) => emit('verify', param, '')"
@expire="emit('expire')"
@error="emit('error')"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import AliyunCaptchaWidget from '@/components/AliyunCaptchaWidget.vue'
// ActionCaptchaResult 动作触发式验证(腾讯/阿里云弹窗)的结果:
// 腾讯 token=ticket、randstr 非空;阿里云 token=captchaVerifyParam、randstr 恒为空。
export interface ActionCaptchaResult {
token: string
randstr: string
}
const props = defineProps<{
siteKey?: string
turnstileEnabled: boolean
turnstileSiteKey: string
tencentEnabled: boolean
tencentAppId: string
tencentRegion?: string
aliyunEnabled?: boolean
aliyunSceneId?: string
aliyunPrefix?: string
aliyunRegion?: string
}>()
const emit = defineEmits<{
verify: [tokenOrTicket: string, randstr: string]
expire: []
error: []
}>()
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
function reset(): void {
turnstileRef.value?.reset()
tencentRef.value?.reset()
aliyunRef.value?.reset()
}
// verifyAction 弹出当前启用的动作触发式验证码(腾讯/阿里云)并等待结果;
// 用户关闭弹窗返回 null,验证异常 emit('error') 并返回 null。
async function verifyAction(): Promise<ActionCaptchaResult | null> {
if (props.tencentEnabled && props.tencentAppId) {
try {
const proof = (await tencentRef.value?.verify()) ?? null
if (!proof) return null
return { token: proof.ticket, randstr: proof.randstr }
} catch {
emit('error')
return null
}
}
if (props.aliyunEnabled && props.aliyunSceneId && props.aliyunPrefix) {
try {
const param = (await aliyunRef.value?.verify()) ?? null
if (!param) return null
return { token: param, randstr: '' }
} catch {
emit('error')
return null
}
}
return null
}
defineExpose({ reset, verifyAction })
</script>
+309
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']
}
}
]
@@ -0,0 +1,203 @@
<template>
<div
v-if="isInternational"
ref="internationalContainerRef"
data-testid="tencent-captcha-international-container"
:class="
internationalContainerVisible
? 'flex min-h-[60px] w-full justify-center'
: 'pointer-events-none fixed left-1/2 top-0 flex h-[60px] w-[302px] -translate-x-1/2 scale-[0.01] opacity-0'
"
/>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
loadTencentCaptcha,
normalizeTencentCaptchaRegion,
type TencentCaptchaProof,
type TencentCaptchaResult
} from '@/utils/tencentCaptcha'
const { locale } = useI18n()
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
const isInternational = computed(
() => normalizeTencentCaptchaRegion(props.region) === 'intl'
)
const internationalContainerRef = ref<HTMLDivElement | null>(null)
const internationalContainerVisible = ref<boolean>(isInternational.value)
let instance: { show(): void; destroy(): void } | null = null
let pending: Promise<TencentCaptchaProof | null> | null = null
let cancelPending: (() => void) | null = null
let cachedProof: TencentCaptchaProof | null = null
let cachedProofCreatedAt = 0
let isMounted = false
// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。
const cachedProofMaxAgeMs = 4 * 60 * 1000
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (callback: () => void, keepInternationalContainer: boolean = false): void => {
if (settled) return
settled = true
if (cancelPending === cancel) cancelPending = null
if (!keepInternationalContainer) {
instance?.destroy()
instance = null
internationalContainerVisible.value = false
}
callback()
}
const cancel = (): void => finish(() => resolve(null))
cancelPending = cancel
const region = normalizeTencentCaptchaRegion(props.region)
if (region === 'intl' && revealInternational) {
internationalContainerVisible.value = true
}
void nextTick()
.then(() => loadTencentCaptcha(region))
.then((TencentCaptcha) => {
if (cancelPending !== cancel) return
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
const handleResult = (result: TencentCaptchaResult): void => {
if (result.ret === 2) {
finish(() => resolve(null))
return
}
const ticket = result.ticket?.trim() || ''
const randstr = result.randstr?.trim() || ''
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
finish(() => reject(new Error('Tencent Captcha verification failed')))
return
}
// 国际站保留已勾选的控件,避免成功回调后页面出现跳动;登录流程结束时由 reset 清理。
finish(() => resolve({ ticket, randstr }), region === 'intl')
}
if (region === 'intl') {
// 新版国际站先在指定容器内展示 Robot checkbox,用户点击后才弹出挑战。
// document.body 会把 checkbox 追加到整个页面末尾,在登录页上通常落到视口外,
// 表现为 SDK 已成功初始化但页面没有任何可见组件。
const container = internationalContainerRef.value
if (!container) {
throw new Error('Tencent Captcha international container is unavailable')
}
instance = new TencentCaptcha(container, props.appId, handleResult, {
// 国际站与国内站保持一致:页面加载后显示 Robot checkbox,由用户主动确认。
enableAutoCheck: false,
userLanguage,
type: 'popup'
})
} else {
instance = new TencentCaptcha(props.appId, handleResult, { userLanguage })
}
instance.show()
})
.catch((error: unknown) => finish(() => reject(error)))
})
}
function verify(): Promise<TencentCaptchaProof | null> {
if (cachedProof) {
if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) {
// 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。
reset(false)
} else {
const proof = cachedProof
cachedProof = null
cachedProofCreatedAt = 0
// 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。
pending = null
return Promise.resolve(proof)
}
}
if (pending) {
const verification = pending
internationalContainerVisible.value = true
// 预加载阶段可能因容器不可见而被 SDK 延迟绘制,提交时在容器显示后重新触发同一个实例。
void nextTick().then(() => {
if (pending === verification) instance?.show()
})
return verification.then((proof) => {
if (cachedProof === proof) {
cachedProof = null
cachedProofCreatedAt = 0
}
return proof
})
}
internationalContainerVisible.value = true
const verification = createVerificationPromise(true)
pending = verification
void verification.then(
() => {
if (pending === verification) pending = null
},
() => {
if (pending === verification) pending = null
}
)
return verification
}
function preload(): void {
if (!isInternational.value || pending || cachedProof) return
// 国际站要求首屏直接展示 checkbox,避免用户点击登录后才看到验证码。
const verification = createVerificationPromise(true)
pending = verification
void verification
.then((proof) => {
if (proof) {
cachedProof = proof
cachedProofCreatedAt = Date.now()
}
})
.catch(() => undefined)
.finally(() => {
if (pending === verification) pending = null
})
}
function reset(reinitialize: boolean = true): void {
instance?.destroy()
instance = null
cancelPending?.()
cancelPending = null
pending = null
cachedProof = null
cachedProofCreatedAt = 0
internationalContainerVisible.value = isInternational.value
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
if (reinitialize && isMounted && isInternational.value) {
void nextTick().then(() => {
if (isMounted) preload()
})
}
}
onMounted(() => {
isMounted = true
preload()
})
onBeforeUnmount(() => {
isMounted = false
reset()
})
defineExpose({ verify, reset })
</script>
+182
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>
@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import AliyunCaptchaWidget from '../AliyunCaptchaWidget.vue'
interface CapturedInitOptions {
SceneId: string
prefix: string
mode: string
element: string
button: string
captchaVerifyCallback: (param: string) => { captchaResult: boolean }
language?: string
}
const i18nStub = {
install(app: { config: { globalProperties: Record<string, unknown> } }) {
app.config.globalProperties.$t = (key: string) => key
}
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({ locale: { value: 'zh-CN' }, t: (key: string) => key })
}))
describe('AliyunCaptchaWidget', () => {
let initOptions: CapturedInitOptions | null
beforeEach(() => {
initOptions = null
window.initAliyunCaptcha = vi.fn((options: CapturedInitOptions) => {
initOptions = options
}) as unknown as typeof window.initAliyunCaptcha
})
afterEach(() => {
vi.useRealTimers()
delete window.initAliyunCaptcha
delete window.AliyunCaptchaConfig
document.getElementById('aliyunCaptcha-window-popup')?.remove()
document.getElementById('aliyunCaptcha-mask')?.remove()
})
function mountWidget() {
return mount(AliyunCaptchaWidget, {
props: { sceneId: 'scene-1', prefix: 'prefix-1', region: 'cn' as const },
attachTo: document.body,
global: { plugins: [i18nStub] }
})
}
function createVisiblePopup(): HTMLElement {
const popup = document.createElement('div')
popup.id = 'aliyunCaptcha-window-popup'
popup.style.display = 'block'
document.body.appendChild(popup)
return popup
}
it('渲染可见验证按钮并以 popup 模式初始化,全局配置就位', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const button = wrapper.get('button')
expect(button.text()).toContain('auth.captchaClickToVerify')
expect(window.AliyunCaptchaConfig).toEqual({ region: 'cn', prefix: 'prefix-1' })
expect(initOptions).not.toBeNull()
expect(initOptions!.mode).toBe('popup')
expect(initOptions!.SceneId).toBe('scene-1')
expect(initOptions!.language).toBe('cn')
wrapper.unmount()
})
it('用户点击按钮进入验证中,验证完成后 emit verify 并置已通过', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
await wrapper.get('button').trigger('click')
expect(wrapper.get('button').text()).toContain('auth.captchaVerifying')
const result = initOptions!.captchaVerifyCallback('captcha-param-1')
expect(result).toEqual({ captchaResult: true })
await wrapper.vm.$nextTick()
expect(wrapper.emitted('verify')).toEqual([['captcha-param-1']])
expect(wrapper.get('button').text()).toContain('auth.captchaVerified')
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('verify() 在已预验证时直接复用缓存的 captchaVerifyParam', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
await wrapper.get('button').trigger('click')
initOptions!.captchaVerifyCallback('captcha-param-2')
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
await expect(vm.verify()).resolves.toBe('captcha-param-2')
wrapper.unmount()
})
it('verify() 在未预验证时触发弹窗流程并等待结果', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
const pending = vm.verify()
await Promise.resolve()
await Promise.resolve()
await wrapper.vm.$nextTick()
expect(wrapper.get('button').text()).toContain('auth.captchaVerifying')
initOptions!.captchaVerifyCallback('captcha-param-3')
await expect(pending).resolves.toBe('captcha-param-3')
wrapper.unmount()
})
it('弹窗未出现前会按 tick 重试触发按钮(SDK 异步绑定兜底)', async () => {
vi.useFakeTimers()
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
void vm.verify()
await Promise.resolve()
await Promise.resolve()
const button = wrapper.get('button').element as HTMLButtonElement
const clickSpy = vi.fn()
button.addEventListener('click', clickSpy)
await vi.advanceTimersByTimeAsync(1000)
expect(clickSpy.mock.calls.length).toBeGreaterThanOrEqual(3)
button.removeEventListener('click', clickSpy)
wrapper.unmount()
})
it('弹窗出现后被用户关闭时 resolve null 并回到未验证态', async () => {
vi.useFakeTimers()
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
const vm = wrapper.vm as unknown as { verify: () => Promise<string | null> }
const pending = vm.verify()
await Promise.resolve()
await Promise.resolve()
const popup = createVisiblePopup()
await vi.advanceTimersByTimeAsync(400)
popup.remove()
await vi.advanceTimersByTimeAsync(400)
await expect(pending).resolves.toBeNull()
expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify')
wrapper.unmount()
})
it('reset() 清空缓存并取消进行中的验证', async () => {
const wrapper = mountWidget()
await Promise.resolve()
await Promise.resolve()
await wrapper.get('button').trigger('click')
initOptions!.captchaVerifyCallback('captcha-param-4')
const vm = wrapper.vm as unknown as {
verify: () => Promise<string | null>
reset: () => void
}
vm.reset()
await wrapper.vm.$nextTick()
expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify')
// reset 后缓存失效,verify() 重新走弹窗流程
const pending = vm.verify()
await Promise.resolve()
initOptions!.captchaVerifyCallback('captcha-param-5')
await expect(pending).resolves.toBe('captcha-param-5')
wrapper.unmount()
})
})
declare global {
interface Window {
initAliyunCaptcha?: (options: unknown) => void
AliyunCaptchaConfig?: { region: string; prefix: string }
}
}
@@ -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()
})
})
@@ -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)
})
})
@@ -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()
})
})
@@ -0,0 +1,351 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import { loadTencentCaptcha, resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
const locale = { value: 'zh' }
vi.mock('vue-i18n', () => ({
useI18n: () => ({ locale })
}))
type CaptchaResult = {
ret: number
ticket?: string | null
randstr?: string | null
errorCode?: number
}
describe('TencentCaptchaGate', () => {
beforeEach(() => {
locale.value = 'zh'
delete window.TencentCaptcha
delete window.TCaptchaGlobal
document.head
.querySelectorAll('script[src*="TJCaptcha.js"], script[src*="TJNCaptcha-global.js"]')
.forEach((node) => node.remove())
resetTencentCaptchaLoaderForTest()
})
it('does not render a visible verification button', () => {
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
expect(wrapper.find('button').exists()).toBe(false)
})
it('resolves proof after Tencent SDK success', async () => {
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = vi.fn()
destroy = vi.fn()
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const verification = wrapper.vm.verify()
await flushPromises()
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
await expect(verification).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
})
it('resolves null when the user closes the popup', async () => {
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = vi.fn()
destroy = vi.fn()
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const verification = wrapper.vm.verify()
await flushPromises()
callback?.({ ret: 2, ticket: null })
await expect(verification).resolves.toBeNull()
})
it('rejects SDK load failures and disaster-recovery tickets', async () => {
const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const loadVerification = failedLoad.vm.verify()
await flushPromises()
const script = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
expect(script).not.toBeNull()
script?.dispatchEvent(new Event('error'))
await expect(loadVerification).rejects.toThrow('Failed to load Tencent Captcha SDK')
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = vi.fn()
destroy = vi.fn()
}
const failedResult = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const resultVerification = failedResult.vm.verify()
await flushPromises()
callback?.({ ret: 0, ticket: 'trerror_1001_123456789', randstr: '@fallback', errorCode: 1001 })
await expect(resultVerification).rejects.toThrow('Tencent Captcha verification failed')
})
it('reuses one pending promise for concurrent verify calls', async () => {
const show = vi.fn()
let callback: ((result: CaptchaResult) => void) | undefined
window.TencentCaptcha = class {
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
callback = resultCallback
}
show = show
destroy = vi.fn()
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const first = wrapper.vm.verify()
const second = wrapper.vm.verify()
await flushPromises()
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
await expect(first).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
await expect(second).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
expect(show).toHaveBeenCalledOnce()
})
// 国际站新版 SDK 会先把 Robot checkbox 渲染到首参容器,点击后才弹出挑战。
// 容器必须位于当前表单中;传 document.body 会让 checkbox 落在页面末尾并超出视口。
it('passes a visible in-form container first for the international site', async () => {
const args: unknown[][] = []
let callback: ((result: CaptchaResult) => void) | undefined
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
args.push(received)
callback = received[2] as (result: CaptchaResult) => void
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
const verification = wrapper.vm.verify()
await flushPromises()
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
expect(args).toHaveLength(1)
expect(args[0][0]).toBe(container.element)
expect(args[0][0]).not.toBe(document.body)
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
expect(args[0][1]).toBe('123456789')
expect(typeof args[0][2]).toBe('function')
expect(args[0][3]).toMatchObject({ enableAutoCheck: false, type: 'popup' })
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
await expect(verification).resolves.toEqual({
ticket: 'ticket-value',
randstr: 'rand-value'
})
expect(container.classes()).not.toContain('scale-[0.01]')
})
it('preloads and displays the international checkbox on mount', async () => {
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
await flushPromises()
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
})
it('reuses a preloaded proof when the SDK reports success', async () => {
let callback: ((result: CaptchaResult) => void) | undefined
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
callback = received[2] as (result: CaptchaResult) => void
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
callback?.({ ret: 0, ticket: 'preloaded-ticket', randstr: 'preloaded-rand' })
await flushPromises()
await expect(wrapper.vm.verify()).resolves.toEqual({
ticket: 'preloaded-ticket',
randstr: 'preloaded-rand'
})
expect(wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()).not.toContain(
'scale-[0.01]'
)
})
it('refreshes a preloaded proof before the provider ticket expires', async () => {
vi.useFakeTimers()
try {
let callback: ((result: CaptchaResult) => void) | undefined
let constructorCount = 0
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
constructorCount += 1
callback = received[2] as (result: CaptchaResult) => void
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
callback?.({ ret: 0, ticket: 'expired-ticket', randstr: 'expired-rand' })
await flushPromises()
vi.advanceTimersByTime(4 * 60 * 1000)
const verification = wrapper.vm.verify()
await flushPromises()
expect(constructorCount).toBe(2)
callback?.({ ret: 0, ticket: 'fresh-ticket', randstr: 'fresh-rand' })
await expect(verification).resolves.toEqual({
ticket: 'fresh-ticket',
randstr: 'fresh-rand'
})
} finally {
vi.useRealTimers()
}
})
it('keeps the three-argument form for the Chinese mainland site', async () => {
const args: unknown[][] = []
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
args.push(received)
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
void wrapper.vm.verify()
await flushPromises()
expect(args[0][0]).toBe('123456789')
expect(typeof args[0][1]).toBe('function')
})
it('loads the international SDK script for the international site', async () => {
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
void wrapper.vm.verify()
await flushPromises()
const script = document.head.querySelector<HTMLScriptElement>(
'script[src*="TJNCaptcha-global.js"]'
)
expect(script?.src).toBe('https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js')
})
// 页面上已有的全局构造函数可能来自另一个站点(例如开发期 HMR 重置了模块状态)。
// 两个站点签名不兼容,错配会抛错,因此站点不一致时必须重新加载对应脚本而不是复用。
it('does not reuse a mainland global for the international site', async () => {
window.TencentCaptcha = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
void wrapper.vm.verify()
await flushPromises()
expect(
document.head.querySelector('script[src*="TJNCaptcha-global.js"]')
).not.toBeNull()
})
it('does not inject a second SDK when the region changes in one page', async () => {
const constructor = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const firstLoad = loadTencentCaptcha('cn')
const firstScript = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
expect(firstScript).not.toBeNull()
window.TencentCaptcha = constructor
firstScript?.dispatchEvent(new Event('load'))
await expect(firstLoad).resolves.toBe(constructor)
await expect(loadTencentCaptcha('intl')).rejects.toThrow(
'Tencent Captcha region changed; reload the page to apply it'
)
expect(document.head.querySelector('script[src*="TJNCaptcha-global.js"]')).toBeNull()
})
it('settles a pending verification when reset', async () => {
const destroy = vi.fn()
window.TencentCaptcha = class {
constructor(_appId: string, _callback: (result: CaptchaResult) => void) {}
show = vi.fn()
destroy = destroy
}
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const verification = wrapper.vm.verify()
await flushPromises()
wrapper.vm.reset()
await expect(verification).resolves.toBeNull()
expect(destroy).toHaveBeenCalledOnce()
})
it('reinitializes the international checkbox after reset', async () => {
const destroy = vi.fn()
let constructorCount = 0
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor() {
constructorCount += 1
}
show = vi.fn()
destroy = destroy
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
expect(constructorCount).toBe(1)
wrapper.vm.reset()
await flushPromises()
expect(destroy).toHaveBeenCalledOnce()
expect(constructorCount).toBe(2)
expect(
wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()
).not.toContain('scale-[0.01]')
})
})
@@ -0,0 +1,190 @@
<template>
<div class="flex flex-col gap-0.5">
<!-- 并发槽位 -->
<CapacityBadge :color-class="concurrencyClass" :current="currentConcurrency" :max="account.concurrency">
<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="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>
</CapacityBadge>
<!-- 5h窗口费用限制 -->
<CapacityBadge v-if="showWindowCost" :color-class="windowCostClass" :tooltip="windowCostTooltip" :current="'$' + formatCost(currentWindowCost)" :max="'$' + formatCost(account.window_cost_limit)">
<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>
</CapacityBadge>
<!-- 会话数量限制 -->
<CapacityBadge v-if="showSessionLimit" :color-class="sessionLimitClass" :tooltip="sessionLimitTooltip" :current="activeSessions" :max="account.max_sessions!">
<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>
</CapacityBadge>
<!-- RPM 限制 -->
<CapacityBadge v-if="showRpmLimit" :color-class="rpmClass" :tooltip="rpmTooltip" :current="currentRPM" :max="account.base_rpm!" :suffix="rpmStrategyTag">
<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>
</CapacityBadge>
<!-- 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 CapacityBadge from '@/components/account/CapacityBadge.vue'
import QuotaBadge from '@/components/account/QuotaBadge.vue'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
// ====== 并发 ======
const currentConcurrency = computed(() => props.account.current_concurrency || 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 isAnthropicOAuthOrSetupToken = computed(() =>
props.account.platform === 'anthropic' &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
)
const showWindowCost = computed(() =>
isAnthropicOAuthOrSetupToken.value &&
props.account.window_cost_limit != null &&
props.account.window_cost_limit > 0
)
const currentWindowCost = computed(() => props.account.current_window_cost ?? 0)
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 showSessionLimit = computed(() =>
isAnthropicOAuthOrSetupToken.value &&
props.account.max_sessions != null &&
props.account.max_sessions > 0
)
const activeSessions = computed(() => props.account.active_sessions ?? 0)
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(() =>
isAnthropicOAuthOrSetupToken.value &&
props.account.base_rpm != null &&
props.account.base_rpm > 0
)
const currentRPM = computed(() => props.account.current_rpm ?? 0)
const rpmStrategy = computed(() => props.account.rpm_strategy || 'tiered')
const rpmStrategyTag = computed(() => rpmStrategy.value === 'sticky_exempt' ? '[S]' : '[T]')
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)
})
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'
})
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')
}
})
// 格式化费用显示
const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0'
return value.toFixed(2)
}
// ====== 配额 ======
const isQuotaEligible = computed(() => props.account.type === 'apikey' || props.account.type === 'bedrock')
const showDailyQuota = computed(() =>
isQuotaEligible.value && props.account.quota_daily_limit != null && props.account.quota_daily_limit > 0
)
const showWeeklyQuota = computed(() =>
isQuotaEligible.value && props.account.quota_weekly_limit != null && props.account.quota_weekly_limit > 0
)
const showTotalQuota = computed(() =>
isQuotaEligible.value && props.account.quota_limit != null && props.account.quota_limit > 0
)
</script>
@@ -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>
@@ -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>

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