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
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
/** 缺省 probe;antigravity 仅支持 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
/** 0–100 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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user