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

This commit is contained in:
李建琦
2026-08-21 18:30:13 +08:00
commit 6d655c9903
3584 changed files with 1270640 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 忽略编译后的文件
vite.config.js
vite.config.d.ts
# 忽略依赖
node_modules/
# 忽略构建输出
dist/
../backend/internal/web/dist/
# 忽略缓存
.cache/
.vite/
+36
View File
@@ -0,0 +1,36 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
ecmaVersion: "latest",
sourceType: "module",
extraFileExtensions: [".vue"],
},
plugins: ["vue", "@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:vue/vue3-essential",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-constant-condition": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"vue/multi-word-component-names": "off",
"vue/no-use-v-if-with-v-for": "off",
},
};
+4
View File
@@ -0,0 +1,4 @@
legacy-peer-deps=true
# 允许运行所有包的构建脚本
# esbuild 和 vue-demi 是已知安全的包,需要 postinstall 脚本才能正常工作
ignore-scripts=false
+118
View File
@@ -0,0 +1,118 @@
{
"actions": [
{
"action": "review",
"module": "xlsx",
"resolves": [
{
"id": 1108110,
"path": ".>xlsx",
"dev": false,
"bundled": false,
"optional": false
},
{
"id": 1108111,
"path": ".>xlsx",
"dev": false,
"bundled": false,
"optional": false
}
]
}
],
"advisories": {
"1108110": {
"findings": [
{
"version": "0.18.5",
"paths": [
".>xlsx"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://nvd.nist.gov/vuln/detail/CVE-2023-30533\n- https://cdn.sheetjs.com/advisories/CVE-2023-30533\n- https://git.sheetjs.com/sheetjs/sheetjs/src/branch/master/CHANGELOG.md\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2667\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2986\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-4r6h-8v6p-xvw6",
"created": "2023-04-24T09:30:19.000Z",
"id": 1108110,
"npm_advisory_id": null,
"overview": "All versions of SheetJS CE through 0.19.2 are vulnerable to \"Prototype Pollution\" when reading specially crafted files. Workflows that do not read arbitrary files (for example, exporting data to spreadsheet files) are unaffected.\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.19.3 can be downloaded via https://cdn.sheetjs.com/.",
"reported_by": null,
"title": "Prototype Pollution in sheetJS",
"metadata": null,
"cves": [
"CVE-2023-30533"
],
"access": "public",
"severity": "high",
"module_name": "xlsx",
"vulnerable_versions": "<0.19.3",
"github_advisory_id": "GHSA-4r6h-8v6p-xvw6",
"recommendation": "None",
"patched_versions": "<0.0.0",
"updated": "2025-09-19T15:23:41.000Z",
"cvss": {
"score": 7.8,
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"
},
"cwe": [
"CWE-1321"
],
"url": "https://github.com/advisories/GHSA-4r6h-8v6p-xvw6"
},
"1108111": {
"findings": [
{
"version": "0.18.5",
"paths": [
".>xlsx"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://nvd.nist.gov/vuln/detail/CVE-2024-22363\n- https://cdn.sheetjs.com/advisories/CVE-2024-22363\n- https://cwe.mitre.org/data/definitions/1333.html\n- https://git.sheetjs.com/sheetjs/sheetjs/src/tag/v0.20.2\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-5pgg-2g8v-p4x9",
"created": "2024-04-05T06:30:46.000Z",
"id": 1108111,
"npm_advisory_id": null,
"overview": "SheetJS Community Edition before 0.20.2 is vulnerable.to Regular Expression Denial of Service (ReDoS).\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.20.2 can be downloaded via https://cdn.sheetjs.com/.",
"reported_by": null,
"title": "SheetJS Regular Expression Denial of Service (ReDoS)",
"metadata": null,
"cves": [
"CVE-2024-22363"
],
"access": "public",
"severity": "high",
"module_name": "xlsx",
"vulnerable_versions": "<0.20.2",
"github_advisory_id": "GHSA-5pgg-2g8v-p4x9",
"recommendation": "None",
"patched_versions": "<0.0.0",
"updated": "2025-09-19T15:23:26.000Z",
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"cwe": [
"CWE-1333"
],
"url": "https://github.com/advisories/GHSA-5pgg-2g8v-p4x9"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 2,
"critical": 0
},
"dependencies": 639,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 639
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sub2API - AI API Gateway</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
{
"name": "sub2api-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"typecheck": "vue-tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@airwallex/components-sdk": "^1.30.2",
"@lobehub/icons": "^4.0.2",
"@stripe/stripe-js": "^9.0.1",
"@tanstack/vue-virtual": "^3.13.23",
"@vueuse/core": "^10.7.0",
"axios": "^1.18.0",
"chart.js": "^4.4.1",
"dompurify": "^3.3.1",
"driver.js": "^1.4.0",
"file-saver": "^2.0.5",
"marked": "^17.0.1",
"pinia": "^2.1.7",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-chartjs": "^5.3.0",
"vue-draggable-plus": "^0.6.1",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@intlify/message-compiler": "9.14.5",
"@types/dompurify": "^3.0.5",
"@types/file-saver": "^2.0.7",
"@types/mdx": "^2.0.13",
"@types/node": "^20.10.5",
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "^2.1.9",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.25.0",
"jsdom": "^24.1.3",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "~5.6.0",
"vite": "^5.0.10",
"vite-plugin-checker": "^0.9.1",
"vitest": "^2.1.9",
"vue-tsc": "^2.2.0"
},
"pnpm": {
"overrides": {
"js-cookie": "3.0.7",
"form-data@<4.0.6": ">=4.0.6",
"postcss@<8.5.18": ">=8.5.18"
}
}
}
+9711
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title desc">
<title id="title">Sub2API</title>
<desc id="desc">An interlocking S symbol representing subscription routing into APIs.</desc>
<defs>
<linearGradient id="s2a-bg" x1="72" y1="44" x2="442" y2="478" gradientUnits="userSpaceOnUse">
<stop stop-color="#142B56"/>
<stop offset=".52" stop-color="#0A1A39"/>
<stop offset="1" stop-color="#061127"/>
</linearGradient>
<radialGradient id="s2a-ambient" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(168 92) rotate(51) scale(342 382)">
<stop stop-color="#3E68B0" stop-opacity=".28"/>
<stop offset="1" stop-color="#3E68B0" stop-opacity="0"/>
</radialGradient>
<linearGradient id="s2a-brand" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#79F4BD"/>
<stop offset=".48" stop-color="#39D9E7"/>
<stop offset="1" stop-color="#3875F6"/>
</linearGradient>
<filter id="s2a-shadow" x="-25%" y="-25%" width="150%" height="165%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy=".55" stdDeviation=".65" flood-color="#020817" flood-opacity=".34"/>
</filter>
</defs>
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-bg)"/>
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-ambient)"/>
<rect x="16.75" y="16.75" width="478.5" height="478.5" rx="119.25" fill="none" stroke="#A5BFFF" stroke-opacity=".16" stroke-width="1.5"/>
<g transform="translate(52 52) scale(17)" fill="none" stroke="url(#s2a-brand)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.7" filter="url(#s2a-shadow)">
<path d="m19.25 7.65-2.55-3.2a1.33 1.33 0 0 0-1.03-.5H8.58c-.34 0-.67.13-.91.37L4.15 7.65c-.93.88-.6 1.52.65 2.3l9.55 5.97"/>
<path d="m4.75 16.35 2.55 3.2c.25.31.63.5 1.03.5h7.09c.34 0 .67-.13.91-.37l3.52-3.33c.93-.88.6-1.52-.65-2.3L9.65 8.08"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

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

After

Width:  |  Height:  |  Size: 858 B

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

After

Width:  |  Height:  |  Size: 1.2 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

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

After

Width:  |  Height:  |  Size: 1012 B

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

After

Width:  |  Height:  |  Size: 859 B

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

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,363 @@
<template>
<div v-if="sceneId && prefix" class="aliyun-captcha-wrapper">
<button
:id="buttonId"
type="button"
class="aliyun-captcha-button"
:class="state === 'verified' ? 'aliyun-captcha-button--verified' : ''"
:disabled="state === 'verified'"
>
<svg
v-if="state === 'verified'"
class="aliyun-captcha-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<svg
v-else
class="aliyun-captcha-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M9.661 2.237a.531.531 0 01.678 0 11.947 11.947 0 007.078 2.749.5.5 0 01.479.425c.069.52.104 1.05.104 1.59 0 5.162-3.26 9.563-7.834 11.256a.48.48 0 01-.332 0C5.26 16.564 2 12.163 2 7c0-.538.035-1.069.104-1.589a.5.5 0 01.48-.425 11.947 11.947 0 007.077-2.75z"
clip-rule="evenodd"
/>
</svg>
<span>{{ buttonText }}</span>
</button>
<div :id="elementId"></div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
interface AliyunCaptchaVerifyResult {
captchaResult: boolean
bizResult?: boolean
}
interface AliyunCaptchaInitOptions {
SceneId: string
prefix: string
mode: 'popup' | 'embed'
element: string
button: string
captchaVerifyCallback: (
captchaVerifyParam: string
) => AliyunCaptchaVerifyResult | Promise<AliyunCaptchaVerifyResult>
onBizResultCallback: (bizResult: boolean) => void
getInstance: (instance: unknown) => void
slideStyle?: { width: number; height: number }
language?: string
}
declare global {
interface Window {
initAliyunCaptcha?: (options: AliyunCaptchaInitOptions) => void
AliyunCaptchaConfig?: { region: string; prefix: string }
}
}
const props = withDefaults(
defineProps<{
sceneId: string
prefix: string
region?: 'cn' | 'sgp'
}>(),
{
region: 'cn'
}
)
const emit = defineEmits<{
(e: 'verify', param: string): void
(e: 'expire'): void
(e: 'error'): void
}>()
const { t, locale } = useI18n()
const uid = Math.random().toString(36).slice(2, 10)
const buttonId = `aliyun-captcha-button-${uid}`
const elementId = `aliyun-captcha-element-${uid}`
// idle: 未验证可点击;verifying: 弹窗已拉起(关闭后回到 idle 可重试);verified: 已通过
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
const buttonText = computed(() => {
switch (state.value) {
case 'verified':
return t('auth.captchaVerified')
case 'verifying':
return t('auth.captchaVerifying')
default:
return t('auth.captchaClickToVerify')
}
})
const SCRIPT_SRC = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
const POPUP_ID = 'aliyunCaptcha-window-popup'
const MASK_ID = 'aliyunCaptcha-mask'
const POPUP_OPEN_TIMEOUT_MS = 8000
const POPUP_WATCH_INTERVAL_MS = 300
// captchaVerifyParam 是一次性参数:verified 后缓存于此,提交失败需 reset 后重新验证
let cachedParam: string | null = null
let pending: { resolve: (value: string | null) => void } | null = null
let popupWatchTimer: number | null = null
let readyPromise: Promise<void> | null = null
const loadScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
// 全局配置必须在脚本加载前就位(region/prefix 全站一致,重复赋值无副作用)
window.AliyunCaptchaConfig = { region: props.region, prefix: props.prefix }
if (window.initAliyunCaptcha) {
resolve()
return
}
const existingScript = document.querySelector<HTMLScriptElement>(
'script[src*="aliyunCaptcha/AliyunCaptcha"]'
)
if (existingScript) {
existingScript.addEventListener('load', () => resolve())
existingScript.addEventListener('error', () =>
reject(new Error('Failed to load Aliyun captcha script'))
)
return
}
const script = document.createElement('script')
script.src = SCRIPT_SRC
script.async = true
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Aliyun captcha script'))
document.head.appendChild(script)
})
}
function initCaptcha(): void {
if (!window.initAliyunCaptcha) {
throw new Error('Aliyun captcha script not ready')
}
window.initAliyunCaptcha({
SceneId: props.sceneId,
prefix: props.prefix,
mode: 'popup',
element: `#${elementId}`,
button: `#${buttonId}`,
// 这里不发业务请求,只把 captchaVerifyParam 当 token 交给页面,随登录/注册等
// 业务请求的 turnstile_token 字段提交,由后端在业务接口内调阿里云校验。
captchaVerifyCallback: (captchaVerifyParam: string) => {
onCaptchaParam(captchaVerifyParam)
return { captchaResult: true }
},
onBizResultCallback: () => {},
getInstance: () => {},
slideStyle: { width: 360, height: 40 },
language: locale.value.toLowerCase().startsWith('zh') ? 'cn' : 'en'
})
}
function ensureReady(): Promise<void> {
if (!readyPromise) {
readyPromise = loadScript().then(() => initCaptcha())
readyPromise.catch(() => {
// 失败后允许下次重试(如网络恢复)
readyPromise = null
})
}
return readyPromise
}
function onCaptchaParam(param: string): void {
stopPopupWatch()
cachedParam = param
state.value = 'verified'
emit('verify', param)
const current = pending
pending = null
current?.resolve(param)
}
function settlePending(value: string | null): void {
const current = pending
pending = null
current?.resolve(value)
}
function isPopupVisible(): boolean {
const popup = document.getElementById(POPUP_ID)
if (!popup) return false
return window.getComputedStyle(popup).display !== 'none'
}
function stopPopupWatch(): void {
if (popupWatchTimer !== null) {
window.clearInterval(popupWatchTimer)
popupWatchTimer = null
}
}
// SDK 没有用户关闭弹窗的回调,靠轮询弹窗可见性兜底:
// 弹窗出现过又消失且未产生 param → 用户主动关闭;迟迟未出现 → 打开失败。
// initAliyunCaptcha 对触发按钮的事件绑定是异步完成的,首次 click 可能落空,
// 因此弹窗出现前每个 tick 重试触发一次。
function startPopupWatch(): void {
stopPopupWatch()
const startedAt = Date.now()
let seen = false
popupWatchTimer = window.setInterval(() => {
if (state.value === 'verified') {
stopPopupWatch()
return
}
if (isPopupVisible()) {
seen = true
return
}
if (seen || Date.now() - startedAt > POPUP_OPEN_TIMEOUT_MS) {
stopPopupWatch()
state.value = 'idle'
settlePending(null)
return
}
document.getElementById(buttonId)?.click()
}, POPUP_WATCH_INTERVAL_MS)
}
// 用户点击与程序化触发共用:置 verifying 并启动弹窗监视(幂等,重试 click 不重置计时)
function handleTriggerClick(): void {
if (state.value === 'verified') {
return
}
state.value = 'verifying'
if (popupWatchTimer === null) {
startPopupWatch()
}
}
// 程序化触发验证(OAuth 启动、passkey、未预验证时的表单提交兜底):
// 已通过预验证则直接复用缓存的 captchaVerifyParam;否则弹出验证码等待结果。
// 用户关闭/未能弹出 resolve null;脚本加载失败 reject。
async function verify(): Promise<string | null> {
if (state.value === 'verified' && cachedParam) {
return cachedParam
}
settlePending(null)
await ensureReady()
return new Promise<string | null>((resolve) => {
pending = { resolve }
document.getElementById(buttonId)?.click()
})
}
// 重置为未验证态;captchaVerifyParam 是一次性参数,服务端校验失败后需用户重新验证
function reset(): void {
stopPopupWatch()
settlePending(null)
cachedParam = null
state.value = 'idle'
}
defineExpose({ verify, reset })
onMounted(async () => {
if (!props.sceneId || !props.prefix) {
return
}
document.getElementById(buttonId)?.addEventListener('click', handleTriggerClick)
try {
await ensureReady()
} catch (error) {
console.error('Failed to initialize Aliyun captcha:', error)
emit('error')
}
})
onUnmounted(() => {
document.getElementById(buttonId)?.removeEventListener('click', handleTriggerClick)
stopPopupWatch()
settlePending(null)
// SDK 不会自清理弹窗 DOM,残留会导致下次挂载时回调重复触发
document.getElementById(MASK_ID)?.remove()
document.getElementById(POPUP_ID)?.remove()
})
</script>
<style scoped>
.aliyun-captcha-wrapper {
width: 100%;
}
.aliyun-captcha-button {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgb(209 213 219);
background-color: rgb(249 250 251);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: rgb(55 65 81);
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.aliyun-captcha-button:hover:not(:disabled) {
border-color: rgb(156 163 175);
background-color: rgb(243 244 246);
}
.aliyun-captcha-button--verified {
border-color: rgb(34 197 94);
background-color: rgb(240 253 244);
color: rgb(21 128 61);
}
:root.dark .aliyun-captcha-button,
.dark .aliyun-captcha-button {
border-color: rgb(55 65 81);
background-color: rgb(31 41 55);
color: rgb(209 213 219);
}
:root.dark .aliyun-captcha-button:hover:not(:disabled),
.dark .aliyun-captcha-button:hover:not(:disabled) {
border-color: rgb(75 85 99);
background-color: rgb(55 65 81);
}
:root.dark .aliyun-captcha-button--verified,
.dark .aliyun-captcha-button--verified {
border-color: rgb(34 197 94);
background-color: rgb(20 83 45 / 0.3);
color: rgb(134 239 172);
}
.aliyun-captcha-icon {
height: 1rem;
width: 1rem;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,97 @@
<template>
<TurnstileWidget
v-if="turnstileEnabled && turnstileSiteKey"
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="(token) => emit('verify', token, '')"
@expire="emit('expire')"
@error="emit('error')"
/>
<TencentCaptchaGate
v-else-if="tencentEnabled && tencentAppId"
ref="tencentRef"
:app-id="tencentAppId"
:region="tencentRegion"
/>
<AliyunCaptchaWidget
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
ref="aliyunRef"
:scene-id="aliyunSceneId"
:prefix="aliyunPrefix"
:region="aliyunRegion === 'sgp' ? 'sgp' : 'cn'"
@verify="(param: string) => emit('verify', param, '')"
@expire="emit('expire')"
@error="emit('error')"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import AliyunCaptchaWidget from '@/components/AliyunCaptchaWidget.vue'
// ActionCaptchaResult 动作触发式验证(腾讯/阿里云弹窗)的结果:
// 腾讯 token=ticket、randstr 非空;阿里云 token=captchaVerifyParam、randstr 恒为空。
export interface ActionCaptchaResult {
token: string
randstr: string
}
const props = defineProps<{
siteKey?: string
turnstileEnabled: boolean
turnstileSiteKey: string
tencentEnabled: boolean
tencentAppId: string
tencentRegion?: string
aliyunEnabled?: boolean
aliyunSceneId?: string
aliyunPrefix?: string
aliyunRegion?: string
}>()
const emit = defineEmits<{
verify: [tokenOrTicket: string, randstr: string]
expire: []
error: []
}>()
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
function reset(): void {
turnstileRef.value?.reset()
tencentRef.value?.reset()
aliyunRef.value?.reset()
}
// verifyAction 弹出当前启用的动作触发式验证码(腾讯/阿里云)并等待结果;
// 用户关闭弹窗返回 null,验证异常 emit('error') 并返回 null。
async function verifyAction(): Promise<ActionCaptchaResult | null> {
if (props.tencentEnabled && props.tencentAppId) {
try {
const proof = (await tencentRef.value?.verify()) ?? null
if (!proof) return null
return { token: proof.ticket, randstr: proof.randstr }
} catch {
emit('error')
return null
}
}
if (props.aliyunEnabled && props.aliyunSceneId && props.aliyunPrefix) {
try {
const param = (await aliyunRef.value?.verify()) ?? null
if (!param) return null
return { token: param, randstr: '' }
} catch {
emit('error')
return null
}
}
return null
}
defineExpose({ reset, verifyAction })
</script>
+309
View File
@@ -0,0 +1,309 @@
import { DriveStep } from 'driver.js'
/**
* 管理员完整引导流程
* 交互式引导:指引用户实际操作
* @param t 国际化函数
* @param isSimpleMode 是否为简易模式(简易模式下会过滤分组相关步骤)
*/
export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false): DriveStep[] => {
const allSteps: DriveStep[] = [
// ========== 欢迎介绍 ==========
{
popover: {
title: t('onboarding.admin.welcome.title'),
description: t('onboarding.admin.welcome.description'),
align: 'center',
nextBtnText: t('onboarding.admin.welcome.nextBtn'),
prevBtnText: t('onboarding.admin.welcome.prevBtn')
}
},
// ========== 第一部分:创建分组 ==========
{
element: '#sidebar-group-manage',
popover: {
title: t('onboarding.admin.groupManage.title'),
description: t('onboarding.admin.groupManage.description'),
side: 'right',
align: 'center',
showButtons: ['close'],
}
},
{
element: '[data-tour="groups-create-btn"]',
popover: {
title: t('onboarding.admin.createGroup.title'),
description: t('onboarding.admin.createGroup.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="group-form-name"]',
popover: {
title: t('onboarding.admin.groupName.title'),
description: t('onboarding.admin.groupName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-platform"]',
popover: {
title: t('onboarding.admin.groupPlatform.title'),
description: t('onboarding.admin.groupPlatform.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-multiplier"]',
popover: {
title: t('onboarding.admin.groupMultiplier.title'),
description: t('onboarding.admin.groupMultiplier.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-exclusive"]',
popover: {
title: t('onboarding.admin.groupExclusive.title'),
description: t('onboarding.admin.groupExclusive.description'),
side: 'top',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-submit"]',
popover: {
title: t('onboarding.admin.groupSubmit.title'),
description: t('onboarding.admin.groupSubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
},
// ========== 第二部分:创建账号授权 ==========
{
element: '#sidebar-channel-manage',
popover: {
title: t('onboarding.admin.accountManage.title'),
description: t('onboarding.admin.accountManage.description'),
side: 'right',
align: 'center',
showButtons: ['close']
}
},
{
element: '[data-tour="accounts-create-btn"]',
popover: {
title: t('onboarding.admin.createAccount.title'),
description: t('onboarding.admin.createAccount.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="account-form-name"]',
popover: {
title: t('onboarding.admin.accountName.title'),
description: t('onboarding.admin.accountName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-platform"]',
popover: {
title: t('onboarding.admin.accountPlatform.title'),
description: t('onboarding.admin.accountPlatform.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-type"]',
popover: {
title: t('onboarding.admin.accountType.title'),
description: t('onboarding.admin.accountType.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-priority"]',
popover: {
title: t('onboarding.admin.accountPriority.title'),
description: t('onboarding.admin.accountPriority.description'),
side: 'top',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-groups"]',
popover: {
title: t('onboarding.admin.accountGroups.title'),
description: t('onboarding.admin.accountGroups.description'),
side: 'top',
align: 'center',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-submit"]',
popover: {
title: t('onboarding.admin.accountSubmit.title'),
description: t('onboarding.admin.accountSubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
},
// ========== 第三部分:创建API密钥 ==========
{
element: '[data-tour="sidebar-my-keys"]',
popover: {
title: t('onboarding.admin.keyManage.title'),
description: t('onboarding.admin.keyManage.description'),
side: 'right',
align: 'center',
showButtons: ['close']
}
},
{
element: '[data-tour="keys-create-btn"]',
popover: {
title: t('onboarding.admin.createKey.title'),
description: t('onboarding.admin.createKey.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="key-form-name"]',
popover: {
title: t('onboarding.admin.keyName.title'),
description: t('onboarding.admin.keyName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-group"]',
popover: {
title: t('onboarding.admin.keyGroup.title'),
description: t('onboarding.admin.keyGroup.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-submit"]',
popover: {
title: t('onboarding.admin.keySubmit.title'),
description: t('onboarding.admin.keySubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
}
]
// 简易模式下过滤分组相关步骤
if (isSimpleMode) {
return allSteps.filter(step => {
const element = step.element as string | undefined
// 过滤掉分组管理和账号分组选择相关步骤
return !element || (
!element.includes('sidebar-group-manage') &&
!element.includes('groups-create-btn') &&
!element.includes('group-form-') &&
!element.includes('account-form-groups')
)
})
}
return allSteps
}
/**
* 普通用户引导流程
*/
export const getUserSteps = (t: (key: string) => string): DriveStep[] => [
{
popover: {
title: t('onboarding.user.welcome.title'),
description: t('onboarding.user.welcome.description'),
align: 'center',
nextBtnText: t('onboarding.user.welcome.nextBtn'),
prevBtnText: t('onboarding.user.welcome.prevBtn')
}
},
{
element: '[data-tour="sidebar-my-keys"]',
popover: {
title: t('onboarding.user.keyManage.title'),
description: t('onboarding.user.keyManage.description'),
side: 'right',
align: 'center',
showButtons: ['close']
}
},
{
element: '[data-tour="keys-create-btn"]',
popover: {
title: t('onboarding.user.createKey.title'),
description: t('onboarding.user.createKey.description'),
side: 'bottom',
align: 'end',
showButtons: ['close']
}
},
{
element: '[data-tour="key-form-name"]',
popover: {
title: t('onboarding.user.keyName.title'),
description: t('onboarding.user.keyName.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-group"]',
popover: {
title: t('onboarding.user.keyGroup.title'),
description: t('onboarding.user.keyGroup.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-submit"]',
popover: {
title: t('onboarding.user.keySubmit.title'),
description: t('onboarding.user.keySubmit.description'),
side: 'left',
align: 'center',
showButtons: ['close']
}
}
]
@@ -0,0 +1,203 @@
<template>
<div
v-if="isInternational"
ref="internationalContainerRef"
data-testid="tencent-captcha-international-container"
:class="
internationalContainerVisible
? 'flex min-h-[60px] w-full justify-center'
: 'pointer-events-none fixed left-1/2 top-0 flex h-[60px] w-[302px] -translate-x-1/2 scale-[0.01] opacity-0'
"
/>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
loadTencentCaptcha,
normalizeTencentCaptchaRegion,
type TencentCaptchaProof,
type TencentCaptchaResult
} from '@/utils/tencentCaptcha'
const { locale } = useI18n()
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
const isInternational = computed(
() => normalizeTencentCaptchaRegion(props.region) === 'intl'
)
const internationalContainerRef = ref<HTMLDivElement | null>(null)
const internationalContainerVisible = ref<boolean>(isInternational.value)
let instance: { show(): void; destroy(): void } | null = null
let pending: Promise<TencentCaptchaProof | null> | null = null
let cancelPending: (() => void) | null = null
let cachedProof: TencentCaptchaProof | null = null
let cachedProofCreatedAt = 0
let isMounted = false
// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。
const cachedProofMaxAgeMs = 4 * 60 * 1000
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (callback: () => void, keepInternationalContainer: boolean = false): void => {
if (settled) return
settled = true
if (cancelPending === cancel) cancelPending = null
if (!keepInternationalContainer) {
instance?.destroy()
instance = null
internationalContainerVisible.value = false
}
callback()
}
const cancel = (): void => finish(() => resolve(null))
cancelPending = cancel
const region = normalizeTencentCaptchaRegion(props.region)
if (region === 'intl' && revealInternational) {
internationalContainerVisible.value = true
}
void nextTick()
.then(() => loadTencentCaptcha(region))
.then((TencentCaptcha) => {
if (cancelPending !== cancel) return
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
const handleResult = (result: TencentCaptchaResult): void => {
if (result.ret === 2) {
finish(() => resolve(null))
return
}
const ticket = result.ticket?.trim() || ''
const randstr = result.randstr?.trim() || ''
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
finish(() => reject(new Error('Tencent Captcha verification failed')))
return
}
// 国际站保留已勾选的控件,避免成功回调后页面出现跳动;登录流程结束时由 reset 清理。
finish(() => resolve({ ticket, randstr }), region === 'intl')
}
if (region === 'intl') {
// 新版国际站先在指定容器内展示 Robot checkbox,用户点击后才弹出挑战。
// document.body 会把 checkbox 追加到整个页面末尾,在登录页上通常落到视口外,
// 表现为 SDK 已成功初始化但页面没有任何可见组件。
const container = internationalContainerRef.value
if (!container) {
throw new Error('Tencent Captcha international container is unavailable')
}
instance = new TencentCaptcha(container, props.appId, handleResult, {
// 国际站与国内站保持一致:页面加载后显示 Robot checkbox,由用户主动确认。
enableAutoCheck: false,
userLanguage,
type: 'popup'
})
} else {
instance = new TencentCaptcha(props.appId, handleResult, { userLanguage })
}
instance.show()
})
.catch((error: unknown) => finish(() => reject(error)))
})
}
function verify(): Promise<TencentCaptchaProof | null> {
if (cachedProof) {
if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) {
// 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。
reset(false)
} else {
const proof = cachedProof
cachedProof = null
cachedProofCreatedAt = 0
// 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。
pending = null
return Promise.resolve(proof)
}
}
if (pending) {
const verification = pending
internationalContainerVisible.value = true
// 预加载阶段可能因容器不可见而被 SDK 延迟绘制,提交时在容器显示后重新触发同一个实例。
void nextTick().then(() => {
if (pending === verification) instance?.show()
})
return verification.then((proof) => {
if (cachedProof === proof) {
cachedProof = null
cachedProofCreatedAt = 0
}
return proof
})
}
internationalContainerVisible.value = true
const verification = createVerificationPromise(true)
pending = verification
void verification.then(
() => {
if (pending === verification) pending = null
},
() => {
if (pending === verification) pending = null
}
)
return verification
}
function preload(): void {
if (!isInternational.value || pending || cachedProof) return
// 国际站要求首屏直接展示 checkbox,避免用户点击登录后才看到验证码。
const verification = createVerificationPromise(true)
pending = verification
void verification
.then((proof) => {
if (proof) {
cachedProof = proof
cachedProofCreatedAt = Date.now()
}
})
.catch(() => undefined)
.finally(() => {
if (pending === verification) pending = null
})
}
function reset(reinitialize: boolean = true): void {
instance?.destroy()
instance = null
cancelPending?.()
cancelPending = null
pending = null
cachedProof = null
cachedProofCreatedAt = 0
internationalContainerVisible.value = isInternational.value
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
if (reinitialize && isMounted && isInternational.value) {
void nextTick().then(() => {
if (isMounted) preload()
})
}
}
onMounted(() => {
isMounted = true
preload()
})
onBeforeUnmount(() => {
isMounted = false
reset()
})
defineExpose({ verify, reset })
</script>

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