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
@@ -0,0 +1,14 @@
|
||||
# 忽略编译后的文件
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
|
||||
# 忽略依赖
|
||||
node_modules/
|
||||
|
||||
# 忽略构建输出
|
||||
dist/
|
||||
../backend/internal/web/dist/
|
||||
|
||||
# 忽略缓存
|
||||
.cache/
|
||||
.vite/
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
legacy-peer-deps=true
|
||||
# 允许运行所有包的构建脚本
|
||||
# esbuild 和 vue-demi 是已知安全的包,需要 postinstall 脚本才能正常工作
|
||||
ignore-scripts=false
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import NavigationProgress from '@/components/common/NavigationProgress.vue'
|
||||
import AdminComplianceDialog from '@/components/admin/AdminComplianceDialog.vue'
|
||||
import { resolveRouteDocumentTitle } from '@/router/title'
|
||||
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore, useAdminComplianceStore, useAdminSettingsStore } from '@/stores'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
import { updateFavicon } from '@/utils/branding'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const subscriptionStore = useSubscriptionStore()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
const adminComplianceStore = useAdminComplianceStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
|
||||
function updateDocumentTitle() {
|
||||
const customMenuItems = [
|
||||
...(appStore.cachedPublicSettings?.custom_menu_items ?? []),
|
||||
...(authStore.isAdmin ? adminSettingsStore.customMenuItems : []),
|
||||
]
|
||||
document.title = resolveRouteDocumentTitle(route, appStore.siteName, customMenuItems)
|
||||
}
|
||||
|
||||
// Watch for site settings changes and update favicon/title
|
||||
watch(
|
||||
() => appStore.siteLogo,
|
||||
(newLogo) => {
|
||||
if (newLogo) {
|
||||
updateFavicon(newLogo)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
() => route.fullPath,
|
||||
() => route.meta.title,
|
||||
() => route.meta.titleKey,
|
||||
() => appStore.siteName,
|
||||
() => appStore.cachedPublicSettings?.custom_menu_items,
|
||||
() => authStore.isAdmin,
|
||||
() => adminSettingsStore.customMenuItems,
|
||||
],
|
||||
updateDocumentTitle,
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Watch for authentication state and manage subscription data + announcements
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && authStore.isAuthenticated) {
|
||||
announcementStore.fetchAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
function onAdminComplianceRequired(event: Event) {
|
||||
const detail = (event as CustomEvent<Record<string, string>>).detail || {}
|
||||
adminComplianceStore.requireAcknowledgement(detail)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(isAuthenticated, oldValue) => {
|
||||
if (isAuthenticated) {
|
||||
if (authStore.isAdmin) {
|
||||
adminComplianceStore.fetchStatus().catch((error) => {
|
||||
console.error('Failed to fetch admin compliance status:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// User logged in: preload subscriptions and start polling
|
||||
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
|
||||
console.error('Failed to preload subscriptions:', error)
|
||||
})
|
||||
subscriptionStore.startPolling()
|
||||
|
||||
// Announcements: new login vs page refresh restore
|
||||
if (oldValue === false) {
|
||||
// New login: delay 3s then force fetch
|
||||
setTimeout(() => announcementStore.fetchAnnouncements(true), 3000)
|
||||
} else {
|
||||
// Page refresh restore (oldValue was undefined)
|
||||
announcementStore.fetchAnnouncements()
|
||||
}
|
||||
|
||||
// Register visibility change listener
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
} else {
|
||||
// User logged out: clear data and stop polling
|
||||
subscriptionStore.clear()
|
||||
announcementStore.reset()
|
||||
adminComplianceStore.reset()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Route change trigger (throttled by store)
|
||||
router.afterEach(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
announcementStore.fetchAnnouncements()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('admin-compliance-required', onAdminComplianceRequired)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('admin-compliance-required', onAdminComplianceRequired)
|
||||
|
||||
// Check if setup is needed
|
||||
try {
|
||||
const status = await getSetupStatus()
|
||||
if (status.needs_setup && route.path !== '/setup') {
|
||||
router.replace('/setup')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// If setup endpoint fails, assume normal mode and continue
|
||||
}
|
||||
|
||||
// Load public settings into appStore (will be cached for other components)
|
||||
await appStore.fetchPublicSettings()
|
||||
|
||||
// Re-resolve document title now that site settings are available
|
||||
updateDocumentTitle()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationProgress />
|
||||
<RouterView />
|
||||
<Toast />
|
||||
<AnnouncementPopup />
|
||||
<AdminComplianceDialog />
|
||||
</template>
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
|
||||
|
||||
const showError = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
const showWarning = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
importData: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
const mountModal = () =>
|
||||
mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const makeJsonFile = (name: string, content: string, type = 'application/json') => {
|
||||
const file = new File([content], name, { type })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve(content)
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
const setInputFiles = (element: Element, files: File[]) => {
|
||||
Object.defineProperty(element, 'files', {
|
||||
value: files,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('ImportDataModal', () => {
|
||||
beforeEach(async () => {
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showWarning.mockReset()
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockReset()
|
||||
})
|
||||
|
||||
it('未选择文件时提示错误', async () => {
|
||||
const wrapper = mountModal()
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
|
||||
})
|
||||
|
||||
it('无效 JSON 时按文件名提示解析失败', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
const wrapper = mountModal()
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
setInputFiles(input.element, [makeJsonFile('data.json', 'invalid json')])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailedFile')
|
||||
expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('不是导出数据的 JSON 按文件名拒绝', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
const wrapper = mountModal()
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
setInputFiles(input.element, [makeJsonFile('random.json', JSON.stringify({ name: 'test' }))])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportInvalidFile')
|
||||
expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('无有效 JSON 的选择不清空已有选择', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
|
||||
proxy_created: 0,
|
||||
proxy_reused: 0,
|
||||
proxy_failed: 0,
|
||||
account_created: 1,
|
||||
account_failed: 0
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
const valid = makeJsonFile(
|
||||
'valid.json',
|
||||
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
|
||||
)
|
||||
setInputFiles(input.element, [valid])
|
||||
await input.trigger('change')
|
||||
|
||||
setInputFiles(input.element, [new File(['hello'], 'notes.txt', { type: 'text/plain' })])
|
||||
await input.trigger('change')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
accounts: [{ name: 'a' }]
|
||||
}),
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
})
|
||||
|
||||
it('merges multiple selected JSON files before importing', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
|
||||
proxy_created: 0,
|
||||
proxy_reused: 0,
|
||||
proxy_failed: 0,
|
||||
account_created: 2,
|
||||
account_failed: 0
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const first = makeJsonFile(
|
||||
'first.json',
|
||||
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
|
||||
)
|
||||
const second = makeJsonFile(
|
||||
'second.json',
|
||||
JSON.stringify({
|
||||
exported_at: '2026-07-05T00:00:01Z',
|
||||
proxies: [{ proxy_key: 'p' }],
|
||||
accounts: [{ name: 'b' }]
|
||||
})
|
||||
)
|
||||
setInputFiles(input.element, [first, second])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
proxies: [{ proxy_key: 'p' }],
|
||||
accounts: [{ name: 'a' }, { name: 'b' }]
|
||||
}),
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.dataImportSuccess')
|
||||
})
|
||||
|
||||
it('部分成功时关闭弹窗仍通知父组件刷新', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
|
||||
proxy_created: 0,
|
||||
proxy_reused: 0,
|
||||
proxy_failed: 0,
|
||||
account_created: 1,
|
||||
account_failed: 1
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
setInputFiles(input.element, [
|
||||
makeJsonFile(
|
||||
'mixed.json',
|
||||
JSON.stringify({
|
||||
exported_at: '2026-07-05T00:00:00Z',
|
||||
proxies: [],
|
||||
accounts: [{ name: 'a' }, { name: 'b' }]
|
||||
})
|
||||
)
|
||||
])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportCompletedWithErrors')
|
||||
expect(wrapper.emitted('imported')).toBeUndefined()
|
||||
|
||||
// 第二个 btn-secondary 是 footer 的取消按钮(第一个是选择文件)
|
||||
await wrapper.findAll('button.btn-secondary')[1]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('imported')).toHaveLength(1)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* 导航集成测试
|
||||
* 测试完整的页面导航流程、预加载和错误恢复机制
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createRouter, createWebHistory, type Router } from 'vue-router'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { useNavigationLoadingState, _resetNavigationLoadingInstance } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
|
||||
// Mock 视图组件
|
||||
const MockDashboard = defineComponent({
|
||||
name: 'MockDashboard',
|
||||
render() {
|
||||
return h('div', { class: 'dashboard' }, 'Dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
const MockKeys = defineComponent({
|
||||
name: 'MockKeys',
|
||||
render() {
|
||||
return h('div', { class: 'keys' }, 'Keys')
|
||||
}
|
||||
})
|
||||
|
||||
const MockUsage = defineComponent({
|
||||
name: 'MockUsage',
|
||||
render() {
|
||||
return h('div', { class: 'usage' }, 'Usage')
|
||||
}
|
||||
})
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
checkAuth: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
siteName: 'Test Site'
|
||||
})
|
||||
}))
|
||||
|
||||
// 创建测试路由
|
||||
function createTestRouter(): Router {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'Keys',
|
||||
component: MockKeys,
|
||||
meta: { requiresAuth: true, title: 'Keys' }
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: MockUsage,
|
||||
meta: { requiresAuth: true, title: 'Usage' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 测试用 App 组件
|
||||
const TestApp = defineComponent({
|
||||
name: 'TestApp',
|
||||
setup() {
|
||||
return () => h('div', { id: 'app' }, [h('router-view')])
|
||||
}
|
||||
})
|
||||
|
||||
describe('Navigation Integration Tests', () => {
|
||||
let router: Router
|
||||
let originalRequestIdleCallback: typeof window.requestIdleCallback
|
||||
let originalCancelIdleCallback: typeof window.cancelIdleCallback
|
||||
|
||||
beforeEach(() => {
|
||||
// 设置 Pinia
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// 重置导航加载状态
|
||||
_resetNavigationLoadingInstance()
|
||||
|
||||
// 创建新的路由实例
|
||||
router = createTestRouter()
|
||||
|
||||
// Mock requestIdleCallback
|
||||
originalRequestIdleCallback = window.requestIdleCallback
|
||||
originalCancelIdleCallback = window.cancelIdleCallback
|
||||
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
|
||||
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
window.requestIdleCallback = originalRequestIdleCallback
|
||||
window.cancelIdleCallback = originalCancelIdleCallback
|
||||
})
|
||||
|
||||
describe('完整页面导航流程', () => {
|
||||
it('导航时应该触发加载状态变化', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
// 初始状态
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
// 挂载应用
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
// 等待路由初始化
|
||||
await router.isReady()
|
||||
await flushPromises()
|
||||
|
||||
// 导航到 /dashboard
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 导航结束后状态应该重置
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('导航到新页面应该正确渲染组件', async () => {
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 检查当前路由
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('连续快速导航应该正确处理路由状态', async () => {
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
|
||||
// 快速连续导航
|
||||
router.push('/keys')
|
||||
router.push('/usage')
|
||||
router.push('/dashboard')
|
||||
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// 应该最终停在 /dashboard
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('路由预加载', () => {
|
||||
it('导航后应该触发相关路由预加载', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
const triggerSpy = vi.spyOn(routePrefetch, 'triggerPrefetch')
|
||||
|
||||
// 设置 afterEach 守卫
|
||||
router.afterEach((to) => {
|
||||
routePrefetch.triggerPrefetch(to)
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 应该触发预加载
|
||||
expect(triggerSpy).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('已预加载的路由不应重复预加载', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 手动触发预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const prefetchedCount = routePrefetch.prefetchedRoutes.value.size
|
||||
|
||||
// 再次触发相同路由预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 预加载数量不应增加
|
||||
expect(routePrefetch.prefetchedRoutes.value.size).toBe(prefetchedCount)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('路由变化时应取消之前的预加载任务', async () => {
|
||||
const routePrefetch = useRoutePrefetch()
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
}
|
||||
})
|
||||
|
||||
await router.isReady()
|
||||
|
||||
// 触发预加载
|
||||
routePrefetch.triggerPrefetch(router.currentRoute.value)
|
||||
|
||||
// 立即导航到新路由(这会在内部调用 cancelPendingPrefetch)
|
||||
routePrefetch.triggerPrefetch({ path: '/keys' } as any)
|
||||
|
||||
// 由于 triggerPrefetch 内部调用 cancelPendingPrefetch,检查是否有预加载被正确管理
|
||||
expect(routePrefetch.prefetchedRoutes.value.size).toBeLessThanOrEqual(2)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chunk 加载错误恢复', () => {
|
||||
it('chunk 加载失败应该被正确捕获', async () => {
|
||||
const errorHandler = vi.fn()
|
||||
|
||||
// 创建带错误处理的路由
|
||||
const errorRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/error-page',
|
||||
name: 'ErrorPage',
|
||||
// 模拟加载失败的组件
|
||||
component: () => Promise.reject(new Error('Failed to fetch dynamically imported module'))
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
errorRouter.onError(errorHandler)
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [errorRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await errorRouter.isReady()
|
||||
await errorRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 尝试导航到会失败的页面
|
||||
try {
|
||||
await errorRouter.push('/error-page')
|
||||
} catch {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// 错误处理器应该被调用
|
||||
expect(errorHandler).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('chunk 加载错误应该包含正确的错误信息', async () => {
|
||||
let capturedError: Error | null = null
|
||||
|
||||
const errorRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/chunk-error',
|
||||
name: 'ChunkError',
|
||||
component: () => {
|
||||
const error = new Error('Loading chunk failed')
|
||||
error.name = 'ChunkLoadError'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
errorRouter.onError((error) => {
|
||||
capturedError = error
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [errorRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await errorRouter.isReady()
|
||||
|
||||
try {
|
||||
await errorRouter.push('/chunk-error')
|
||||
} catch {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(capturedError).not.toBeNull()
|
||||
expect(capturedError!.name).toBe('ChunkLoadError')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('导航状态管理', () => {
|
||||
it('导航开始时 isLoading 应该变为 true', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
// 创建一个延迟加载的组件来模拟真实场景
|
||||
const DelayedComponent = defineComponent({
|
||||
name: 'DelayedComponent',
|
||||
async setup() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return () => h('div', 'Delayed')
|
||||
}
|
||||
})
|
||||
|
||||
const delayRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/delayed',
|
||||
name: 'Delayed',
|
||||
component: DelayedComponent
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 设置导航守卫
|
||||
delayRouter.beforeEach(() => {
|
||||
navigationLoading.startNavigation()
|
||||
})
|
||||
|
||||
delayRouter.afterEach(() => {
|
||||
navigationLoading.endNavigation()
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [delayRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await delayRouter.isReady()
|
||||
await delayRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 导航结束后 isLoading 应该为 false
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('导航取消时应该正确重置状态', async () => {
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
|
||||
const testRouter = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: MockDashboard
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'Keys',
|
||||
component: MockKeys,
|
||||
beforeEnter: (_to, _from, next) => {
|
||||
// 模拟导航取消
|
||||
next(false)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
testRouter.beforeEach(() => {
|
||||
navigationLoading.startNavigation()
|
||||
})
|
||||
|
||||
testRouter.afterEach(() => {
|
||||
navigationLoading.endNavigation()
|
||||
})
|
||||
|
||||
const wrapper = mount(TestApp, {
|
||||
global: {
|
||||
plugins: [testRouter]
|
||||
}
|
||||
})
|
||||
|
||||
await testRouter.isReady()
|
||||
await testRouter.push('/dashboard')
|
||||
await flushPromises()
|
||||
|
||||
// 尝试导航到被取消的路由
|
||||
await testRouter.push('/keys').catch(() => {})
|
||||
await flushPromises()
|
||||
|
||||
// 导航被取消后,状态应该被重置
|
||||
// 注意:由于 afterEach 仍然会被调用,isLoading 应该为 false
|
||||
expect(navigationLoading.isLoading.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
|
||||
|
||||
const showError = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
proxies: {
|
||||
importData: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('Proxy ImportDataModal', () => {
|
||||
beforeEach(() => {
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
})
|
||||
|
||||
it('未选择文件时提示错误', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportSelectFile')
|
||||
})
|
||||
|
||||
it('无效 JSON 时提示解析失败', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve('invalid json')
|
||||
})
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Vitest 测试环境设置
|
||||
* 提供全局 mock 和测试工具
|
||||
*/
|
||||
import { config } from '@vue/test-utils'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear() {
|
||||
values.clear()
|
||||
},
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(values.keys())[index] ?? null
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key)
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.getItem !== 'function') {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: createMemoryStorage()
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.localStorage.getItem !== 'function') {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: globalThis.localStorage
|
||||
})
|
||||
}
|
||||
|
||||
// Mock requestIdleCallback (Safari < 15 不支持)
|
||||
if (typeof globalThis.requestIdleCallback === 'undefined') {
|
||||
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
|
||||
return window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 1)
|
||||
}) as unknown as typeof requestIdleCallback
|
||||
}
|
||||
|
||||
if (typeof globalThis.cancelIdleCallback === 'undefined') {
|
||||
globalThis.cancelIdleCallback = ((id: number) => {
|
||||
window.clearTimeout(id)
|
||||
}) as unknown as typeof cancelIdleCallback
|
||||
}
|
||||
|
||||
// Mock matchMedia (jsdom 未实现;DataTable 等组件依赖它做桌面/移动分支)
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: true, // 测试默认按桌面视口渲染表格
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
|
||||
|
||||
// Mock ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
|
||||
|
||||
// Vue Test Utils 全局配置
|
||||
config.global.stubs = {
|
||||
// 可以在这里添加全局 stub
|
||||
}
|
||||
|
||||
// 设置全局测试超时
|
||||
vi.setConfig({ testTimeout: 10000 })
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post }
|
||||
}))
|
||||
|
||||
import { duplicate } from '@/api/admin/accounts'
|
||||
|
||||
describe('admin account duplicate API', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
it('sends a stable idempotency key with the duplicate request', async () => {
|
||||
const account = await duplicate(42)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/accounts/42/duplicate', undefined, {
|
||||
headers: {
|
||||
'Idempotency-Key': 'account-duplicate-42-11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
expect(account).toEqual({ id: 43, name: 'primary (Copy)' })
|
||||
})
|
||||
|
||||
it('reuses the operation key after an ambiguous failed request', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(99)).rejects.toThrow('network timeout')
|
||||
|
||||
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
|
||||
await duplicate(99)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
const secondHeaders = post.mock.calls[1][2].headers
|
||||
expect(secondHeaders).toEqual(firstHeaders)
|
||||
})
|
||||
|
||||
it('reuses the operation key after a page reload', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(77)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.resetModules()
|
||||
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
|
||||
const { duplicate: duplicateAfterReload } = await import('@/api/admin/accounts')
|
||||
await duplicateAfterReload(77)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post, put, del } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { get, post, put, delete: del }
|
||||
}))
|
||||
|
||||
import {
|
||||
deleteOllamaCloudUsageSession,
|
||||
getOllamaCloudUsage,
|
||||
getOllamaCloudUsageSettings,
|
||||
refreshOllamaCloudUsage,
|
||||
saveOllamaCloudUsageSession,
|
||||
setOllamaCloudUsageAutoRefresh,
|
||||
updateOllamaCloudUsageSettings
|
||||
} from '@/api/admin/accounts'
|
||||
|
||||
const state = {
|
||||
account_id: 7,
|
||||
eligible: true,
|
||||
configured: true,
|
||||
auto_refresh_enabled: false,
|
||||
encryption_key_configured: true
|
||||
}
|
||||
|
||||
describe('admin Ollama Cloud usage API', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
put.mockReset()
|
||||
del.mockReset()
|
||||
})
|
||||
|
||||
it('uses dedicated global settings endpoints', async () => {
|
||||
const settings = { enabled: false, interval_minutes: 60, debounce_minutes: 1 }
|
||||
get.mockResolvedValueOnce({ data: settings })
|
||||
put.mockResolvedValueOnce({ data: settings })
|
||||
|
||||
await expect(getOllamaCloudUsageSettings()).resolves.toEqual(settings)
|
||||
await expect(updateOllamaCloudUsageSettings(settings)).resolves.toEqual(settings)
|
||||
expect(get).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings')
|
||||
expect(put).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings', settings)
|
||||
})
|
||||
|
||||
it('keeps session configuration write-only and separate from account updates', async () => {
|
||||
get.mockResolvedValueOnce({ data: state })
|
||||
put.mockResolvedValueOnce({ data: state }).mockResolvedValueOnce({ data: state })
|
||||
del.mockResolvedValueOnce({ data: { ...state, configured: false } })
|
||||
post.mockResolvedValueOnce({ data: state })
|
||||
|
||||
await expect(getOllamaCloudUsage(7)).resolves.toEqual(state)
|
||||
await expect(saveOllamaCloudUsageSession(7, 'wos-session=secret')).resolves.toEqual(state)
|
||||
await expect(setOllamaCloudUsageAutoRefresh(7, true)).resolves.toEqual(state)
|
||||
await expect(refreshOllamaCloudUsage(7)).resolves.toEqual(state)
|
||||
await expect(deleteOllamaCloudUsageSession(7)).resolves.toMatchObject({ configured: false })
|
||||
|
||||
expect(put).toHaveBeenNthCalledWith(1, '/admin/accounts/7/ollama-cloud-usage/session', { session: 'wos-session=secret' })
|
||||
expect(put).toHaveBeenNthCalledWith(2, '/admin/accounts/7/ollama-cloud-usage/auto-refresh', { enabled: true })
|
||||
expect(post).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/refresh')
|
||||
expect(del).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/session')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post, put } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { get, post, put }
|
||||
}))
|
||||
|
||||
import {
|
||||
getUpstreamBillingProbeSettings,
|
||||
probeUpstreamBilling,
|
||||
probeUpstreamBillingBatch,
|
||||
setUpstreamBillingProbeEnabled,
|
||||
updateUpstreamBillingProbeSettings
|
||||
} from '@/api/admin/accounts'
|
||||
|
||||
describe('admin account upstream billing probe API', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
put.mockReset()
|
||||
})
|
||||
|
||||
it('reads and updates global settings', async () => {
|
||||
const settings = { enabled: true, interval_minutes: 30 }
|
||||
get.mockResolvedValueOnce({ data: settings })
|
||||
put.mockResolvedValueOnce({ data: settings })
|
||||
|
||||
await expect(getUpstreamBillingProbeSettings()).resolves.toEqual(settings)
|
||||
await expect(updateUpstreamBillingProbeSettings(settings)).resolves.toEqual(settings)
|
||||
expect(get).toHaveBeenCalledWith('/admin/accounts/upstream-billing-probe/settings')
|
||||
expect(put).toHaveBeenCalledWith('/admin/accounts/upstream-billing-probe/settings', settings)
|
||||
})
|
||||
|
||||
it('uses dedicated account and batch endpoints', async () => {
|
||||
const result = { account_id: 7, snapshot: { status: 'unsupported' } }
|
||||
put.mockResolvedValueOnce({ data: {} })
|
||||
post.mockResolvedValueOnce({ data: result })
|
||||
post.mockResolvedValueOnce({ data: { results: [result] } })
|
||||
|
||||
await setUpstreamBillingProbeEnabled(7, true)
|
||||
await expect(probeUpstreamBilling(7)).resolves.toEqual(result)
|
||||
await expect(probeUpstreamBillingBatch([7])).resolves.toEqual([result])
|
||||
|
||||
expect(put).toHaveBeenCalledWith('/admin/accounts/7/upstream-billing-probe', { enabled: true })
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/admin/accounts/7/upstream-billing-probe')
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/admin/accounts/upstream-billing-probe/batch', { account_ids: [7] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post },
|
||||
}))
|
||||
|
||||
import { duplicate } from '@/api/admin/channelMonitor'
|
||||
|
||||
describe('admin channel monitor duplicate API', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends a stable idempotency key with the duplicate request', async () => {
|
||||
const monitor = await duplicate(42)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/channel-monitors/42/duplicate', undefined, {
|
||||
headers: {
|
||||
'Idempotency-Key': 'channel-monitor-duplicate-7-42-11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
})
|
||||
expect(monitor).toEqual({ id: 43, name: 'primary (Copy)' })
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after an ambiguous failed request', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(99)).rejects.toThrow('network timeout')
|
||||
|
||||
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
|
||||
await duplicate(99)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after a page reload', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(77)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.resetModules()
|
||||
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
|
||||
const { duplicate: duplicateAfterReload } = await import('@/api/admin/channelMonitor')
|
||||
await duplicateAfterReload(77)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not reuse an operation key across administrators for the same monitor', async () => {
|
||||
post.mockRejectedValueOnce(new Error('first admin timeout'))
|
||||
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
|
||||
const firstAdminHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'22222222-2222-4222-8222-222222222222'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
|
||||
await duplicate(55)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
|
||||
expect(post.mock.calls[1][2].headers).toEqual({
|
||||
'Idempotency-Key': 'channel-monitor-duplicate-8-55-22222222-2222-4222-8222-222222222222',
|
||||
})
|
||||
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:7:55')).toBe(
|
||||
firstAdminHeaders['Idempotency-Key']
|
||||
)
|
||||
expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:8:55')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
|
||||
localStorage.setItem('auth_user', '{invalid json')
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(66)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
|
||||
await duplicate(66)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post },
|
||||
}))
|
||||
|
||||
import { authorizePassword, createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
|
||||
|
||||
describe('admin Grok SSO import API', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { created: [], failed: [] } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 180_000],
|
||||
[3, 180_000],
|
||||
[4, 270_000],
|
||||
[7, 360_000],
|
||||
])('uses a timeout sized for %i keys', async (keyCount, expectedTimeout) => {
|
||||
expect(getGrokSSOImportTimeout(keyCount)).toBe(expectedTimeout)
|
||||
|
||||
await createFromSSO({
|
||||
sso_tokens: Array.from({ length: keyCount }, (_, index) => `sso-${index + 1}`),
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/grok/sso-to-oauth',
|
||||
expect.objectContaining({ sso_tokens: expect.any(Array) }),
|
||||
{ timeout: expectedTimeout },
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves password whitespace and applies the authorization timeout', async () => {
|
||||
post.mockResolvedValueOnce({ data: { access_token: 'access-token' } })
|
||||
|
||||
await authorizePassword(' user@example.com ---- password with spaces ', 7)
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/grok/oauth/password',
|
||||
{
|
||||
email: 'user@example.com',
|
||||
password: ' password with spaces ',
|
||||
proxy_id: 7,
|
||||
},
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post }
|
||||
}))
|
||||
|
||||
import { duplicate } from '@/api/admin/groups'
|
||||
|
||||
describe('admin group duplicate API', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)', status: 'inactive' } })
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends a stable idempotency key with the duplicate request', async () => {
|
||||
const group = await duplicate(42)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/groups/42/duplicate', undefined, {
|
||||
headers: {
|
||||
'Idempotency-Key': 'group-duplicate-7-42-11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
expect(group).toEqual({ id: 43, name: 'primary (Copy)', status: 'inactive' })
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after an ambiguous failed request', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(99)).rejects.toThrow('network timeout')
|
||||
|
||||
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
|
||||
await duplicate(99)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after a page reload', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(77)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.resetModules()
|
||||
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
|
||||
const { duplicate: duplicateAfterReload } = await import('@/api/admin/groups')
|
||||
await duplicateAfterReload(77)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not reuse an operation key across administrators for the same group', async () => {
|
||||
post.mockRejectedValueOnce(new Error('first admin timeout'))
|
||||
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
|
||||
const firstAdminHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'22222222-2222-4222-8222-222222222222'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
|
||||
await duplicate(55)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
|
||||
expect(post.mock.calls[1][2].headers).toEqual({
|
||||
'Idempotency-Key': 'group-duplicate-8-55-22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:7:55')).toBe(
|
||||
firstAdminHeaders['Idempotency-Key']
|
||||
)
|
||||
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:8:55')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
|
||||
localStorage.setItem('auth_user', '{invalid json')
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(66)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
|
||||
await duplicate(66)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { get },
|
||||
}))
|
||||
|
||||
import { getUsageSummary } from '@/api/admin/groups'
|
||||
|
||||
describe('admin group usage summary API', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
get.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it('does not send browser timezone parameters', async () => {
|
||||
const summary = [
|
||||
{ group_id: 1, today_cost: 1.25, yesterday_cost: 2.5, total_cost: 9.75 },
|
||||
]
|
||||
get.mockResolvedValue({ data: summary })
|
||||
|
||||
await expect(getUsageSummary()).resolves.toEqual(summary)
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/admin/groups/usage-summary')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
apiClient: {
|
||||
get,
|
||||
post,
|
||||
},
|
||||
}))
|
||||
|
||||
import { getRollbackVersions, rollback, type RollbackVersionInfo } from '@/api/admin/system'
|
||||
|
||||
describe('admin system rollback API', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
})
|
||||
|
||||
it('getRollbackVersions fetches the rollback version list', async () => {
|
||||
const versions: RollbackVersionInfo[] = [
|
||||
{
|
||||
version: '0.1.146',
|
||||
published_at: '2026-07-07T00:00:00Z',
|
||||
html_url: 'https://github.com/Wei-Shaw/sub2api/releases/tag/v0.1.146'
|
||||
}
|
||||
]
|
||||
get.mockResolvedValue({ data: { versions } })
|
||||
|
||||
const result = await getRollbackVersions()
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/admin/system/rollback-versions')
|
||||
expect(result.versions).toEqual(versions)
|
||||
})
|
||||
|
||||
it('rollback posts the target version in the request body', async () => {
|
||||
post.mockResolvedValue({ data: { message: 'ok', need_restart: true } })
|
||||
|
||||
const result = await rollback('0.1.146')
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/system/rollback',
|
||||
{ version: '0.1.146' },
|
||||
{ timeout: 15 * 60 * 1000 }
|
||||
)
|
||||
expect(result.need_restart).toBe(true)
|
||||
})
|
||||
|
||||
it('rollback without a version posts no body (legacy backup rollback)', async () => {
|
||||
post.mockResolvedValue({ data: { message: 'ok', need_restart: true } })
|
||||
|
||||
await rollback()
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/system/rollback',
|
||||
undefined,
|
||||
{ timeout: 15 * 60 * 1000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post,
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
batchUpdateLimits,
|
||||
bindUserAuthIdentity,
|
||||
type AdminBindAuthIdentityRequest,
|
||||
type AdminBoundAuthIdentity,
|
||||
type BatchUpdateUserLimitsRequest,
|
||||
type BatchUpdateUserLimitsResponse,
|
||||
} from '@/api/admin/users'
|
||||
|
||||
type Assert<T extends true> = T
|
||||
type IsExact<T, U> = (
|
||||
(<G>() => G extends T ? 1 : 2) extends (<G>() => G extends U ? 1 : 2)
|
||||
? ((<G>() => G extends U ? 1 : 2) extends (<G>() => G extends T ? 1 : 2) ? true : false)
|
||||
: false
|
||||
)
|
||||
|
||||
type ExpectedAdminBindAuthIdentityRequest = {
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
issuer?: string
|
||||
metadata?: Record<string, unknown>
|
||||
channel?: {
|
||||
channel: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type ExpectedAdminBoundAuthIdentity = {
|
||||
user_id: number
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
verified_at?: string | null
|
||||
issuer?: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
channel?: {
|
||||
channel: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
} | null
|
||||
}
|
||||
|
||||
const requestContractExact: Assert<
|
||||
IsExact<AdminBindAuthIdentityRequest, ExpectedAdminBindAuthIdentityRequest>
|
||||
> = true
|
||||
const responseContractExact: Assert<
|
||||
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
|
||||
> = true
|
||||
const batchRequestContractExact: Assert<
|
||||
IsExact<
|
||||
BatchUpdateUserLimitsRequest,
|
||||
{
|
||||
user_ids: number[]
|
||||
all?: boolean
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
}
|
||||
>
|
||||
> = true
|
||||
const batchResponseContractExact: Assert<
|
||||
IsExact<BatchUpdateUserLimitsResponse, { affected: number }>
|
||||
> = true
|
||||
|
||||
describe('admin users api auth identity binding', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
})
|
||||
|
||||
it('posts the backend-compatible auth identity bind payload and returns the backend response shape', async () => {
|
||||
const payload: AdminBindAuthIdentityRequest = {
|
||||
provider_type: 'wechat',
|
||||
provider_key: 'wechat-main',
|
||||
provider_subject: 'union-123',
|
||||
metadata: { source: 'admin-repair' },
|
||||
channel: {
|
||||
channel: 'open',
|
||||
channel_app_id: 'wx-open',
|
||||
channel_subject: 'openid-123',
|
||||
metadata: { scene: 'migration' },
|
||||
},
|
||||
}
|
||||
|
||||
const response: AdminBoundAuthIdentity = {
|
||||
user_id: 9,
|
||||
provider_type: 'wechat',
|
||||
provider_key: 'wechat-main',
|
||||
provider_subject: 'union-123',
|
||||
verified_at: '2026-04-22T00:00:00Z',
|
||||
issuer: null,
|
||||
metadata: { source: 'admin-repair' },
|
||||
created_at: '2026-04-22T00:00:00Z',
|
||||
updated_at: '2026-04-22T00:00:00Z',
|
||||
channel: {
|
||||
channel: 'open',
|
||||
channel_app_id: 'wx-open',
|
||||
channel_subject: 'openid-123',
|
||||
metadata: { scene: 'migration' },
|
||||
created_at: '2026-04-22T00:00:00Z',
|
||||
updated_at: '2026-04-22T00:00:00Z',
|
||||
},
|
||||
}
|
||||
post.mockResolvedValue({ data: response })
|
||||
|
||||
const result = await bindUserAuthIdentity(9, payload)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/users/9/auth-identities', payload)
|
||||
expect(result).toEqual(response)
|
||||
})
|
||||
|
||||
it('keeps bind auth identity request and response types aligned with the backend contract', () => {
|
||||
expect(requestContractExact).toBe(true)
|
||||
expect(responseContractExact).toBe(true)
|
||||
})
|
||||
|
||||
it('posts batch limit updates once with only the supplied limit fields', async () => {
|
||||
const request: BatchUpdateUserLimitsRequest = {
|
||||
user_ids: [4, 7],
|
||||
all: false,
|
||||
rpm_limit: 0,
|
||||
}
|
||||
post.mockResolvedValue({ data: { affected: 2 } satisfies BatchUpdateUserLimitsResponse })
|
||||
|
||||
const result = await batchUpdateLimits(request)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/users/batch-limits', request)
|
||||
expect(result).toEqual({ affected: 2 })
|
||||
expect(batchRequestContractExact).toBe(true)
|
||||
expect(batchResponseContractExact).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ADMIN_UI_REQUEST_HEADER,
|
||||
USER_UI_REQUEST_HEADER,
|
||||
isUserTimingAPIPath,
|
||||
shouldMarkAdminUIRequest,
|
||||
shouldMarkUserUIRequest,
|
||||
} from '@/api/adminUIRequest'
|
||||
|
||||
describe('Admin UI request marker', () => {
|
||||
it('uses the stable request header name', () => {
|
||||
expect(ADMIN_UI_REQUEST_HEADER).toBe('X-Admin-UI-Request')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/admin',
|
||||
'/admin/users',
|
||||
'/api/v1/admin',
|
||||
'/api/v1/admin/accounts?status=active',
|
||||
'https://api.example.test/api/v1/admin/dashboard',
|
||||
])('marks Admin API request %s before page navigation', (requestURL) => {
|
||||
expect(shouldMarkAdminUIRequest(requestURL, '/login')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['/keys', '/groups/available', '/auth/me', '/announcements'])(
|
||||
'marks shared request %s while an Admin page is active',
|
||||
(requestURL) => {
|
||||
expect(shouldMarkAdminUIRequest(requestURL, '/admin/dashboard')).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['/keys', '/dashboard'],
|
||||
['/api/v1/administer', '/dashboard'],
|
||||
['/keys', '/administrator'],
|
||||
['', '/'],
|
||||
])('does not mark request %s on page %s', (requestURL, pagePath) => {
|
||||
expect(shouldMarkAdminUIRequest(requestURL, pagePath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User UI request marker', () => {
|
||||
it('uses the stable request header name', () => {
|
||||
expect(USER_UI_REQUEST_HEADER).toBe('X-User-UI-Request')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/me',
|
||||
'/auth/revoke-all-sessions',
|
||||
'/auth/oauth/bind-token',
|
||||
'/user',
|
||||
'/user/profile',
|
||||
'/user/password',
|
||||
'/user/notify-email/send-code',
|
||||
'/user/totp/status',
|
||||
'/user/aff',
|
||||
'/user/platform-quotas',
|
||||
'/keys',
|
||||
'/keys/12',
|
||||
'/groups/available',
|
||||
'/groups/rates',
|
||||
'/channels/available',
|
||||
'/usage',
|
||||
'/usage/stats',
|
||||
'/usage/dashboard/snapshot-v2',
|
||||
'/announcements',
|
||||
'/announcements/3/read',
|
||||
'/redeem',
|
||||
'/redeem/history',
|
||||
'/subscriptions',
|
||||
'/subscriptions/active',
|
||||
'/channel-monitors',
|
||||
'/channel-monitors/9/status',
|
||||
'/payment/config',
|
||||
'/payment/plans',
|
||||
'/payment/orders',
|
||||
'/payment/orders/my',
|
||||
'/api/v1/auth/me',
|
||||
'/api/v1/keys?page=1',
|
||||
'https://api.example.test/api/v1/payment/orders/1',
|
||||
])('marks user timing API %s', (requestURL) => {
|
||||
expect(shouldMarkUserUIRequest(requestURL)).toBe(true)
|
||||
expect(isUserTimingAPIPath(requestURL)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/login',
|
||||
'/settings/public',
|
||||
'/admin/users',
|
||||
'/groups',
|
||||
'/channels',
|
||||
'/payment/public/orders/verify',
|
||||
'/payment/webhook/stripe',
|
||||
'/api/v1/payment/public/orders/resolve',
|
||||
'',
|
||||
])('does not mark non-user timing API %s', (requestURL) => {
|
||||
expect(shouldMarkUserUIRequest(requestURL)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const post = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post }
|
||||
}))
|
||||
|
||||
import {
|
||||
buildOAuthLoginStartURL,
|
||||
startOAuthLogin,
|
||||
type OAuthLoginStart
|
||||
} from '@/api/auth'
|
||||
|
||||
describe('OAuth captcha start API', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
})
|
||||
|
||||
it('posts Tencent captcha proof and preserves OAuth query parameters', async () => {
|
||||
const request: OAuthLoginStart = {
|
||||
provider: 'github',
|
||||
params: { redirect: '/dashboard', aff_code: 'AFF123' }
|
||||
}
|
||||
const proof = {
|
||||
tencent_captcha_ticket: 'ticket',
|
||||
tencent_captcha_randstr: '@rand'
|
||||
}
|
||||
post.mockResolvedValue({ data: { authorize_url: 'https://github.com/login/oauth/authorize' } })
|
||||
|
||||
await expect(startOAuthLogin(request, proof)).resolves.toEqual({
|
||||
authorize_url: 'https://github.com/login/oauth/authorize'
|
||||
})
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/github/start', proof, {
|
||||
params: request.params
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the legacy GET start URL when Tencent captcha is disabled', () => {
|
||||
expect(buildOAuthLoginStartURL({
|
||||
provider: 'wechat',
|
||||
params: { mode: 'open', redirect: '/billing?plan=pro' }
|
||||
})).toBe('/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const post = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post
|
||||
}
|
||||
}))
|
||||
|
||||
describe('oauth adoption auth api', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: {} })
|
||||
localStorage.clear()
|
||||
document.cookie = 'oauth_bind_access_token=; Max-Age=0; path=/'
|
||||
})
|
||||
|
||||
it('posts adoption decisions when exchanging pending oauth completion', async () => {
|
||||
const { exchangePendingOAuthCompletion } = await import('@/api/auth')
|
||||
|
||||
await exchangePendingOAuthCompletion({
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('posts bind-login decisions when finalizing pending oauth bind flow', async () => {
|
||||
const { completePendingOAuthBindLogin } = await import('@/api/auth')
|
||||
|
||||
await completePendingOAuthBindLogin({
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('posts linuxdo invitation completion with adoption decisions', async () => {
|
||||
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeLinuxDoOAuthRegistration('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('posts linuxdo create-account completion with adoption decisions', async () => {
|
||||
const { createPendingLinuxDoOAuthAccount } = await import('@/api/auth')
|
||||
|
||||
await createPendingLinuxDoOAuthAccount('invite-code', {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('posts affiliate code when completing linuxdo oauth registration', async () => {
|
||||
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeLinuxDoOAuthRegistration(
|
||||
'invite-code',
|
||||
{
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
},
|
||||
' AFF123 '
|
||||
)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
aff_code: 'AFF123',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('posts oidc invitation completion with adoption decisions', async () => {
|
||||
const { completeOIDCOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeOIDCOAuthRegistration('invite-code', {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('posts oidc create-account completion with adoption decisions', async () => {
|
||||
const { createPendingOIDCOAuthAccount } = await import('@/api/auth')
|
||||
|
||||
await createPendingOIDCOAuthAccount('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('posts wechat invitation completion with adoption decisions', async () => {
|
||||
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeWeChatOAuthRegistration('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('posts wechat create-account completion with adoption decisions', async () => {
|
||||
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
|
||||
|
||||
await createPendingWeChatOAuthAccount('invite-code', {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: false
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('posts affiliate code when creating pending wechat oauth account', async () => {
|
||||
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
|
||||
|
||||
await createPendingWeChatOAuthAccount(
|
||||
'invite-code',
|
||||
{
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
},
|
||||
'WXAFF'
|
||||
)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
aff_code: 'WXAFF',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies oauth completion results as login or bind', async () => {
|
||||
const { getOAuthCompletionKind } = await import('@/api/auth')
|
||||
|
||||
expect(getOAuthCompletionKind({ access_token: 'access-token' })).toBe('login')
|
||||
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
|
||||
})
|
||||
|
||||
it('provides bind-login utility helpers for invitation and suggested profile states', async () => {
|
||||
const {
|
||||
getPendingOAuthBindLoginKind,
|
||||
hasPendingOAuthSuggestedProfile,
|
||||
isPendingOAuthCreateAccountRequired
|
||||
} = await import('@/api/auth')
|
||||
|
||||
expect(getPendingOAuthBindLoginKind({ access_token: 'access-token' })).toBe('login')
|
||||
expect(getPendingOAuthBindLoginKind({ redirect: '/profile' })).toBe('bind')
|
||||
expect(
|
||||
isPendingOAuthCreateAccountRequired({
|
||||
error: 'invitation_required'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isPendingOAuthCreateAccountRequired({
|
||||
error: 'other'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasPendingOAuthSuggestedProfile({
|
||||
suggested_display_name: 'OAuth Nick'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasPendingOAuthSuggestedProfile({
|
||||
suggested_avatar_url: 'https://cdn.example/avatar.png'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(hasPendingOAuthSuggestedProfile({})).toBe(false)
|
||||
})
|
||||
|
||||
it('requests an HttpOnly oauth bind cookie before redirect binding', async () => {
|
||||
localStorage.setItem('auth_token', 'access-token-value')
|
||||
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
|
||||
|
||||
await prepareOAuthBindAccessTokenCookie()
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/bind-token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apiClient } from '../client'
|
||||
import { getMatrix, repeatedArrayParamsSerializer } from '../channelMonitorV2'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('channel monitor V2 query serialization', () => {
|
||||
it('uses repeated keys without bracket suffixes for array filters', () => {
|
||||
const query = repeatedArrayParamsSerializer({
|
||||
range: '90m',
|
||||
platform: ['openai', 'grok'],
|
||||
group_id: [1, 2],
|
||||
model: undefined,
|
||||
group_by: 'platform_group_model',
|
||||
})
|
||||
|
||||
expect(query).toBe('range=90m&platform=openai&platform=grok&group_id=1&group_id=2&group_by=platform_group_model')
|
||||
expect(query).not.toContain('%5B%5D')
|
||||
})
|
||||
|
||||
it('sends the matrix grouping with the shared filters', async () => {
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({
|
||||
data: { coverage: {}, group_by: 'platform_group', items: [] },
|
||||
})
|
||||
|
||||
await getMatrix({ range: '24h', platforms: ['openai'], groupIds: [7], models: [] }, 'platform_group', true)
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/admin/channel-monitor-v2/matrix', expect.objectContaining({
|
||||
params: {
|
||||
range: '24h',
|
||||
platform: ['openai'],
|
||||
group_id: [7],
|
||||
model: undefined,
|
||||
group_by: 'platform_group',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
|
||||
// 需要在导入 client 之前设置 mock
|
||||
vi.mock('@/i18n', () => ({
|
||||
getLocale: () => 'zh-CN',
|
||||
}))
|
||||
|
||||
describe('API Client', () => {
|
||||
let apiClient: AxiosInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear()
|
||||
window.history.replaceState({}, '', '/')
|
||||
// 每次测试重新导入以获取干净的模块状态
|
||||
vi.resetModules()
|
||||
const mod = await import('@/api/client')
|
||||
apiClient = mod.apiClient
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
// --- 请求拦截器 ---
|
||||
|
||||
describe('请求拦截器', () => {
|
||||
it('规范化相对 API base,避免在回调页拼出相对 v1 路径', async () => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'api/v1')
|
||||
|
||||
const mod = await import('@/api/client')
|
||||
|
||||
expect(mod.apiClient.defaults.baseURL).toBe('/api/v1')
|
||||
expect(mod.buildApiUrl('/auth/oauth/github/callback?code=abc')).toBe(
|
||||
'/api/v1/auth/oauth/github/callback?code=abc'
|
||||
)
|
||||
})
|
||||
|
||||
it('自动附加 Authorization 头', async () => {
|
||||
localStorage.setItem('auth_token', 'my-jwt-token')
|
||||
|
||||
// 拦截实际请求
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBe('Bearer my-jwt-token')
|
||||
})
|
||||
|
||||
it('无 token 时不附加 Authorization 头', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('GET 请求自动附加 timezone 参数', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.params).toHaveProperty('timezone')
|
||||
})
|
||||
|
||||
it('POST 请求不附加 timezone 参数', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.post('/test', { foo: 'bar' })
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.params?.timezone).toBeUndefined()
|
||||
})
|
||||
|
||||
it('请求默认带 withCredentials 以支持跨域 cookie', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.post('/auth/oauth/bind-token')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.withCredentials).toBe(true)
|
||||
})
|
||||
|
||||
it('Admin API 在进入管理页面前也带 Admin UI 标记', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/admin/users')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
|
||||
})
|
||||
|
||||
it('管理页面调用共享 API 时带 Admin UI 标记', async () => {
|
||||
window.history.replaceState({}, '', '/admin/dashboard')
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/groups/available')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
|
||||
})
|
||||
|
||||
it('普通用户页面调用共享 API 时不带 Admin UI 标记', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/groups/available')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('用户侧 timing API 自动带 User UI 标记', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/auth/me')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-User-UI-Request')).toBe('1')
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('支付用户 API 带 User UI 标记,公开支付 API 不带', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/payment/plans')
|
||||
expect(adapter.mock.calls[0][0].headers.get('X-User-UI-Request')).toBe('1')
|
||||
|
||||
await apiClient.post('/payment/public/orders/verify', {})
|
||||
expect(adapter.mock.calls[1][0].headers.get('X-User-UI-Request')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('管理页调用共享 API 时同时带 Admin 与 User UI 标记', async () => {
|
||||
window.history.replaceState({}, '', '/admin/dashboard')
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/keys')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
|
||||
expect(config.headers.get('X-User-UI-Request')).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
// --- 响应拦截器 ---
|
||||
|
||||
describe('响应拦截器', () => {
|
||||
it('code=0 时解包 data 字段', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: { name: 'test' }, message: 'ok' },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
const response = await apiClient.get('/test')
|
||||
expect(response.data).toEqual({ name: 'test' })
|
||||
})
|
||||
|
||||
it('code!=0 时拒绝并返回结构化错误', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 1001, message: '参数错误', data: null },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
code: 1001,
|
||||
message: '参数错误',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('部署与运营合规未确认时广播事件且保留登录态', async () => {
|
||||
localStorage.setItem('auth_token', 'admin-token')
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('admin-compliance-required', listener)
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
response: {
|
||||
status: 423,
|
||||
data: {
|
||||
code: 'ADMIN_COMPLIANCE_ACK_REQUIRED',
|
||||
message: 'administrator compliance acknowledgement is required',
|
||||
metadata: {
|
||||
version: 'v2026.06.10',
|
||||
document_path_zh: 'docs/legal/admin-compliance.zh.md',
|
||||
document_path_en: 'docs/legal/admin-compliance.en.md',
|
||||
},
|
||||
},
|
||||
},
|
||||
config: {
|
||||
url: '/admin/users',
|
||||
headers: { Authorization: 'Bearer admin-token' },
|
||||
},
|
||||
code: 'ERR_BAD_REQUEST',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/admin/users')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 423,
|
||||
code: 'ADMIN_COMPLIANCE_ACK_REQUIRED',
|
||||
metadata: expect.objectContaining({
|
||||
version: 'v2026.06.10',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual(
|
||||
expect.objectContaining({
|
||||
version: 'v2026.06.10',
|
||||
})
|
||||
)
|
||||
expect(localStorage.getItem('auth_token')).toBe('admin-token')
|
||||
|
||||
window.removeEventListener('admin-compliance-required', listener)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 401 Token 刷新 ---
|
||||
|
||||
describe('401 Token 刷新', () => {
|
||||
it('无 refresh_token 时 401 清除 localStorage', async () => {
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
// 不设置 refresh_token
|
||||
|
||||
// Mock window.location
|
||||
const originalLocation = window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...originalLocation, pathname: '/dashboard', href: '/dashboard' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
|
||||
},
|
||||
config: {
|
||||
url: '/test',
|
||||
headers: { Authorization: 'Bearer expired-token' },
|
||||
},
|
||||
code: 'ERR_BAD_REQUEST',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toBeDefined()
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
|
||||
// 恢复 location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('有 refresh_token 时刷新并重试原请求', async () => {
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
localStorage.setItem('refresh_token', 'refresh-token')
|
||||
localStorage.setItem('token_expires_at', String(Date.now() - 1))
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
|
||||
|
||||
const adapter = vi.fn()
|
||||
.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
|
||||
},
|
||||
config: {
|
||||
url: '/test',
|
||||
headers: { Authorization: 'Bearer expired-token' },
|
||||
},
|
||||
code: 'ERR_BAD_REQUEST',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { code: 0, data: { ok: true } },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
vi.spyOn(axios, 'post').mockResolvedValueOnce({
|
||||
data: {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: {
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(apiClient.get('/test')).resolves.toMatchObject({ data: { ok: true } })
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(2)
|
||||
expect(localStorage.getItem('auth_token')).toBe('new-token')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token')
|
||||
expect(adapter.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer new-token')
|
||||
})
|
||||
|
||||
it('刷新期间换号时旧请求不会清除新会话', async () => {
|
||||
localStorage.setItem('auth_token', 'user-a-access')
|
||||
localStorage.setItem('refresh_token', 'user-a-refresh')
|
||||
localStorage.setItem('token_expires_at', String(Date.now() - 1))
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
|
||||
|
||||
apiClient.defaults.adapter = vi.fn().mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
|
||||
},
|
||||
config: {
|
||||
url: '/test',
|
||||
headers: { Authorization: 'Bearer user-a-access' },
|
||||
},
|
||||
code: 'ERR_BAD_REQUEST',
|
||||
})
|
||||
|
||||
let rejectRefresh!: (reason: Error) => void
|
||||
vi.spyOn(axios, 'post').mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => {
|
||||
rejectRefresh = reject
|
||||
})
|
||||
)
|
||||
|
||||
const staleRequest = apiClient.get('/test')
|
||||
await vi.waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1))
|
||||
|
||||
localStorage.setItem('auth_token', 'user-b-access')
|
||||
localStorage.setItem('refresh_token', 'user-b-refresh')
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
|
||||
rejectRefresh(new Error('stale refresh failed'))
|
||||
|
||||
await expect(staleRequest).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' })
|
||||
expect(localStorage.getItem('auth_token')).toBe('user-b-access')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('user-b-refresh')
|
||||
expect(localStorage.getItem('auth_user')).toBe(JSON.stringify({ id: 8 }))
|
||||
expect(window.location.pathname).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
// --- 网络错误 ---
|
||||
|
||||
describe('网络错误', () => {
|
||||
it('网络错误返回 status 0 的错误', async () => {
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
code: 'ERR_NETWORK',
|
||||
message: 'Network Error',
|
||||
config: { url: '/test' },
|
||||
// 没有 response
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 0,
|
||||
message: 'Network error. Please check your connection.',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 请求取消 ---
|
||||
|
||||
describe('请求取消', () => {
|
||||
it('取消的请求保持原始取消错误', async () => {
|
||||
const source = axios.CancelToken.source()
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue(
|
||||
new axios.Cancel('Operation canceled')
|
||||
)
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(
|
||||
apiClient.get('/test', { cancelToken: source.token })
|
||||
).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
credentialGet: vi.fn(),
|
||||
credentialCreate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
delete: remove
|
||||
}
|
||||
}))
|
||||
|
||||
import { passkeyAPI } from '@/api/passkey'
|
||||
|
||||
class FakePublicKeyCredential {
|
||||
id = 'credential-id'
|
||||
rawId = Uint8Array.from([1, 2, 3]).buffer
|
||||
type = 'public-key'
|
||||
authenticatorAttachment = 'platform'
|
||||
response: Record<string, unknown> = {
|
||||
authenticatorData: Uint8Array.from([4, 5]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
signature: Uint8Array.from([8, 9]).buffer,
|
||||
userHandle: Uint8Array.from([10, 11]).buffer
|
||||
}
|
||||
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRegistrationCredential extends FakePublicKeyCredential {
|
||||
constructor() {
|
||||
super()
|
||||
this.response = {
|
||||
attestationObject: Uint8Array.from([12, 13]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
getTransports: () => ['internal']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('passkey api', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
patch.mockReset()
|
||||
remove.mockReset()
|
||||
credentialGet.mockReset()
|
||||
credentialCreate.mockReset()
|
||||
|
||||
vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential)
|
||||
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||
configurable: true,
|
||||
value: FakePublicKeyCredential
|
||||
})
|
||||
Object.defineProperty(navigator, 'credentials', {
|
||||
configurable: true,
|
||||
value: { get: credentialGet, create: credentialCreate }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('converts assertion options and response bytes to WebAuthn JSON', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'one-time-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
rpId: 'sub2api.example.com',
|
||||
userVerification: 'required'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'access',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1 }
|
||||
}
|
||||
})
|
||||
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
|
||||
|
||||
await passkeyAPI.login()
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin')
|
||||
const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions
|
||||
expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3])
|
||||
expect(request.publicKey!.userVerification).toBe('required')
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/auth/passkey/login/finish', {
|
||||
session_token: 'one-time-session',
|
||||
credential: {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQID',
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: 'platform',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
authenticatorData: 'BAU',
|
||||
clientDataJSON: 'Bgc',
|
||||
signature: 'CAk',
|
||||
userHandle: 'Cgs'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('sends Tencent captcha proof only with the passkey begin request', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'one-time-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
rpId: 'sub2api.example.com',
|
||||
userVerification: 'required'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { access_token: 'access', token_type: 'Bearer', user: { id: 1 } } })
|
||||
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
|
||||
|
||||
await passkeyAPI.login({
|
||||
tencent_captcha_ticket: 'ticket-value',
|
||||
tencent_captcha_randstr: '@rand-value'
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin', {
|
||||
tencent_captcha_ticket: 'ticket-value',
|
||||
tencent_captcha_randstr: '@rand-value'
|
||||
})
|
||||
expect(post.mock.calls[1][1]).not.toEqual(expect.objectContaining({
|
||||
tencent_captcha_ticket: expect.anything(),
|
||||
tencent_captcha_randstr: expect.anything()
|
||||
}))
|
||||
})
|
||||
|
||||
it('sends the account password when beginning registration', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'register-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
user: { id: 'BAU', name: 'user@example.com', displayName: 'user' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: 3, name: 'Laptop', created_at: '2026-07-28T00:00:00Z', backup: false }
|
||||
})
|
||||
credentialCreate.mockResolvedValue(new FakeRegistrationCredential())
|
||||
|
||||
await passkeyAPI.register('Laptop', 'hunter2')
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/user/passkeys/register/begin', {
|
||||
password: 'hunter2'
|
||||
})
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/user/passkeys/register/finish', {
|
||||
session_token: 'register-session',
|
||||
name: 'Laptop',
|
||||
credential: expect.objectContaining({
|
||||
response: {
|
||||
attestationObject: 'DA0',
|
||||
clientDataJSON: 'Bgc',
|
||||
transports: ['internal']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the account password when revoking a credential', async () => {
|
||||
remove.mockResolvedValue({ data: null })
|
||||
|
||||
await passkeyAPI.remove(12, 'hunter2')
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('/user/passkeys/12', {
|
||||
data: { password: 'hunter2' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get,
|
||||
post,
|
||||
},
|
||||
}))
|
||||
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
|
||||
describe('payment api', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
get.mockResolvedValue({ data: {} })
|
||||
post.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it('keeps legacy public out_trade_no verification for upgrade compatibility', async () => {
|
||||
await paymentAPI.verifyOrderPublic('legacy-order-no')
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/payment/public/orders/verify', {
|
||||
out_trade_no: 'legacy-order-no',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps signed public resume-token resolve endpoint', async () => {
|
||||
await paymentAPI.resolveOrderPublicByResumeToken('resume-token-123')
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/payment/public/orders/resolve', {
|
||||
resume_token: 'resume-token-123',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizePlatformQuotasMap,
|
||||
sanitizePlatformQuotasMap,
|
||||
type UpdateSettingsRequest,
|
||||
type DefaultPlatformQuotasMap,
|
||||
} from "@/api/admin/settings";
|
||||
|
||||
/** 全 null 的 5 平台 map,用于断言归一化默认值 */
|
||||
const allNullQuotas: DefaultPlatformQuotasMap = {
|
||||
anthropic: { daily: null, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: null },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
grok: { daily: null, weekly: null, monthly: null },
|
||||
}
|
||||
|
||||
describe("admin settings auth source defaults helpers", () => {
|
||||
it("builds auth source defaults state from flat settings fields", () => {
|
||||
const state = buildAuthSourceDefaultsState({
|
||||
auth_source_default_email_balance: 9.5,
|
||||
auth_source_default_email_concurrency: 3,
|
||||
auth_source_default_email_subscriptions: [
|
||||
{ group_id: 1, validity_days: 30 },
|
||||
],
|
||||
auth_source_default_email_grant_on_signup: false,
|
||||
auth_source_default_email_grant_on_first_bind: true,
|
||||
auth_source_default_linuxdo_balance: 6,
|
||||
auth_source_default_linuxdo_concurrency: 8,
|
||||
auth_source_default_linuxdo_subscriptions: [
|
||||
{ group_id: 2, validity_days: 60 },
|
||||
],
|
||||
auth_source_default_linuxdo_grant_on_signup: true,
|
||||
auth_source_default_linuxdo_grant_on_first_bind: false,
|
||||
});
|
||||
|
||||
expect(state.email).toEqual({
|
||||
balance: 9.5,
|
||||
concurrency: 3,
|
||||
subscriptions: [{ group_id: 1, validity_days: 30 }],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: true,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
expect(state.linuxdo).toEqual({
|
||||
balance: 6,
|
||||
concurrency: 8,
|
||||
subscriptions: [{ group_id: 2, validity_days: 60 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
expect(state.oidc).toEqual({
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
expect(state.wechat).toEqual({
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: allNullQuotas,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults grant-on-signup to disabled when settings are missing", () => {
|
||||
const state = buildAuthSourceDefaultsState({});
|
||||
|
||||
expect(state.email.grant_on_signup).toBe(false);
|
||||
expect(state.linuxdo.grant_on_signup).toBe(false);
|
||||
expect(state.oidc.grant_on_signup).toBe(false);
|
||||
expect(state.wechat.grant_on_signup).toBe(false);
|
||||
});
|
||||
|
||||
it("reads nested platform_quotas from settings into auth source state", () => {
|
||||
const state = buildAuthSourceDefaultsState({
|
||||
auth_source_default_email_platform_quotas: {
|
||||
anthropic: { daily: 10, weekly: 50, monthly: 200 },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
} as DefaultPlatformQuotasMap,
|
||||
});
|
||||
|
||||
// anthropic 填写的值应被保留
|
||||
expect(state.email.platform_quotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
|
||||
// openai 全 null 应被保留
|
||||
expect(state.email.platform_quotas.openai).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
// 未出现的平台(gemini/antigravity)归一化为 null
|
||||
expect(state.email.platform_quotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(state.email.platform_quotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("appends auth source defaults back onto update payload", () => {
|
||||
const payload: UpdateSettingsRequest = {
|
||||
site_name: "Sub2API",
|
||||
};
|
||||
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, {
|
||||
email: {
|
||||
balance: 1.25,
|
||||
concurrency: 2,
|
||||
subscriptions: [{ group_id: 3, validity_days: 7 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
linuxdo: {
|
||||
balance: 0,
|
||||
concurrency: 6,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: true,
|
||||
platform_quotas: {},
|
||||
},
|
||||
oidc: {
|
||||
balance: 4,
|
||||
concurrency: 9,
|
||||
subscriptions: [{ group_id: 9, validity_days: 90 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: true,
|
||||
platform_quotas: {},
|
||||
},
|
||||
wechat: {
|
||||
balance: 2,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
github: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
google: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
dingtalk: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
site_name: "Sub2API",
|
||||
auth_source_default_email_balance: 1.25,
|
||||
auth_source_default_email_concurrency: 2,
|
||||
auth_source_default_email_subscriptions: [
|
||||
{ group_id: 3, validity_days: 7 },
|
||||
],
|
||||
auth_source_default_email_grant_on_signup: true,
|
||||
auth_source_default_email_grant_on_first_bind: false,
|
||||
auth_source_default_linuxdo_balance: 0,
|
||||
auth_source_default_linuxdo_concurrency: 6,
|
||||
auth_source_default_linuxdo_subscriptions: [],
|
||||
auth_source_default_linuxdo_grant_on_signup: false,
|
||||
auth_source_default_linuxdo_grant_on_first_bind: true,
|
||||
auth_source_default_oidc_balance: 4,
|
||||
auth_source_default_oidc_concurrency: 9,
|
||||
auth_source_default_oidc_subscriptions: [
|
||||
{ group_id: 9, validity_days: 90 },
|
||||
],
|
||||
auth_source_default_oidc_grant_on_signup: true,
|
||||
auth_source_default_oidc_grant_on_first_bind: true,
|
||||
auth_source_default_wechat_balance: 2,
|
||||
auth_source_default_wechat_concurrency: 5,
|
||||
auth_source_default_wechat_subscriptions: [],
|
||||
auth_source_default_wechat_grant_on_signup: false,
|
||||
auth_source_default_wechat_grant_on_first_bind: false,
|
||||
// 嵌套 platform_quotas 字段
|
||||
auth_source_default_email_platform_quotas: allNullQuotas,
|
||||
auth_source_default_linuxdo_platform_quotas: allNullQuotas,
|
||||
auth_source_default_oidc_platform_quotas: allNullQuotas,
|
||||
auth_source_default_wechat_platform_quotas: allNullQuotas,
|
||||
auth_source_default_github_platform_quotas: allNullQuotas,
|
||||
auth_source_default_google_platform_quotas: allNullQuotas,
|
||||
auth_source_default_dingtalk_platform_quotas: allNullQuotas,
|
||||
});
|
||||
});
|
||||
|
||||
it("appends sanitized nested platform_quotas with non-null values in update payload", () => {
|
||||
const payload: UpdateSettingsRequest = {};
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, {
|
||||
email: {
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
platform_quotas: {
|
||||
anthropic: { daily: 10, weekly: 50, monthly: 200 },
|
||||
openai: { daily: 0, weekly: null, monthly: null },
|
||||
},
|
||||
},
|
||||
linuxdo: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
oidc: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
wechat: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
github: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
google: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
dingtalk: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
|
||||
});
|
||||
|
||||
const emailQuotas = (payload as Record<string, unknown>)["auth_source_default_email_platform_quotas"] as DefaultPlatformQuotasMap;
|
||||
expect(emailQuotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
|
||||
// 0 是合法值(不限额=0 与"不设"不同,保留)
|
||||
expect(emailQuotas.openai?.daily).toBe(0);
|
||||
// 缺失平台归一化为全 null
|
||||
expect(emailQuotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(emailQuotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePlatformQuotasMap", () => {
|
||||
it("填充缺失的平台为全 null 三档", () => {
|
||||
const result = normalizePlatformQuotasMap({ anthropic: { daily: 5, weekly: null, monthly: null } });
|
||||
expect(result.anthropic).toEqual({ daily: 5, weekly: null, monthly: null });
|
||||
expect(result.openai).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.gemini).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(result.grok).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("无参数时返回全 5 平台全 null", () => {
|
||||
const result = normalizePlatformQuotasMap();
|
||||
expect(Object.keys(result)).toHaveLength(5);
|
||||
for (const v of Object.values(result)) {
|
||||
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("非 number 类型的值归一化为 null", () => {
|
||||
const result = normalizePlatformQuotasMap({
|
||||
anthropic: { daily: "50" as unknown as number, weekly: undefined as unknown as number, monthly: null },
|
||||
});
|
||||
expect(result.anthropic).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePlatformQuotasMap", () => {
|
||||
it("保留合法的正数和零值", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
anthropic: { daily: 10.5, weekly: 0, monthly: null },
|
||||
});
|
||||
expect(result.anthropic?.daily).toBe(10.5);
|
||||
expect(result.anthropic?.weekly).toBe(0);
|
||||
expect(result.anthropic?.monthly).toBe(null);
|
||||
});
|
||||
|
||||
it("空字符串(v-model.number 空输入)清洗为 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
anthropic: { daily: "" as unknown as number, weekly: null, monthly: null },
|
||||
});
|
||||
expect(result.anthropic?.daily).toBe(null);
|
||||
});
|
||||
|
||||
it("负数清洗为 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
openai: { daily: -1, weekly: null, monthly: null },
|
||||
});
|
||||
expect(result.openai?.daily).toBe(null);
|
||||
});
|
||||
|
||||
it("NaN/Infinity 清洗为 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({
|
||||
gemini: { daily: NaN, weekly: Infinity, monthly: null },
|
||||
});
|
||||
expect(result.gemini?.daily).toBe(null);
|
||||
expect(result.gemini?.weekly).toBe(null);
|
||||
});
|
||||
|
||||
it("缺失平台填充为全 null", () => {
|
||||
const result = sanitizePlatformQuotasMap({});
|
||||
expect(Object.keys(result)).toHaveLength(5);
|
||||
for (const v of Object.values(result)) {
|
||||
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getPaymentVisibleMethodSourceOptions,
|
||||
normalizePaymentVisibleMethodSource,
|
||||
} from '@/api/admin/settings'
|
||||
|
||||
describe('admin settings payment visible method helpers', () => {
|
||||
it('normalizes aliases into canonical source keys per visible method', () => {
|
||||
expect(normalizePaymentVisibleMethodSource('alipay', 'official')).toBe('official_alipay')
|
||||
expect(normalizePaymentVisibleMethodSource('alipay', 'alipay_direct')).toBe('official_alipay')
|
||||
expect(normalizePaymentVisibleMethodSource('alipay', 'easypay')).toBe('easypay_alipay')
|
||||
|
||||
expect(normalizePaymentVisibleMethodSource('wxpay', 'official')).toBe('official_wxpay')
|
||||
expect(normalizePaymentVisibleMethodSource('wxpay', 'wechat')).toBe('official_wxpay')
|
||||
expect(normalizePaymentVisibleMethodSource('wxpay', 'easypay')).toBe('easypay_wxpay')
|
||||
})
|
||||
|
||||
it('rejects unknown or cross-method source values', () => {
|
||||
expect(normalizePaymentVisibleMethodSource('alipay', 'official_wxpay')).toBe('')
|
||||
expect(normalizePaymentVisibleMethodSource('wxpay', 'official_alipay')).toBe('')
|
||||
expect(normalizePaymentVisibleMethodSource('alipay', 'unknown')).toBe('')
|
||||
expect(normalizePaymentVisibleMethodSource('wxpay', null)).toBe('')
|
||||
})
|
||||
|
||||
it('exposes method-scoped source options instead of arbitrary strings', () => {
|
||||
expect(getPaymentVisibleMethodSourceOptions('alipay')).toEqual([
|
||||
{
|
||||
value: '',
|
||||
labelZh: '未配置',
|
||||
labelEn: 'Not configured',
|
||||
},
|
||||
{
|
||||
value: 'official_alipay',
|
||||
labelZh: '支付宝官方',
|
||||
labelEn: 'Official Alipay',
|
||||
},
|
||||
{
|
||||
value: 'easypay_alipay',
|
||||
labelZh: '易支付支付宝',
|
||||
labelEn: 'EasyPay Alipay',
|
||||
},
|
||||
])
|
||||
|
||||
expect(getPaymentVisibleMethodSourceOptions('wxpay')).toEqual([
|
||||
{
|
||||
value: '',
|
||||
labelZh: '未配置',
|
||||
labelEn: 'Not configured',
|
||||
},
|
||||
{
|
||||
value: 'official_wxpay',
|
||||
labelZh: '微信官方',
|
||||
labelEn: 'Official WeChat Pay',
|
||||
},
|
||||
{
|
||||
value: 'easypay_wxpay',
|
||||
labelZh: '易支付微信',
|
||||
labelEn: 'EasyPay WeChat Pay',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
defaultWeChatConnectScopesForMode,
|
||||
normalizeWeChatConnectMode,
|
||||
} from "@/api/admin/settings";
|
||||
|
||||
describe("admin settings wechat connect helpers", () => {
|
||||
it("normalizes legacy or noisy mode values to the backend contract", () => {
|
||||
expect(normalizeWeChatConnectMode("OPEN")).toBe("open");
|
||||
expect(normalizeWeChatConnectMode(" open_platform ")).toBe("open");
|
||||
expect(normalizeWeChatConnectMode("mp")).toBe("mp");
|
||||
expect(normalizeWeChatConnectMode("official_account")).toBe("mp");
|
||||
expect(normalizeWeChatConnectMode("unknown")).toBe("open");
|
||||
});
|
||||
|
||||
it("maps each mode to the backend default scopes", () => {
|
||||
expect(defaultWeChatConnectScopesForMode("open")).toBe("snsapi_login");
|
||||
expect(defaultWeChatConnectScopesForMode("mp")).toBe("snsapi_userinfo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import axios from 'axios'
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
post: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const mockedPost = vi.mocked(axios.post)
|
||||
|
||||
function seedSession(overrides: Partial<Record<string, string>> = {}): void {
|
||||
localStorage.setItem('auth_token', overrides.auth_token || 'old-access')
|
||||
localStorage.setItem('refresh_token', overrides.refresh_token || 'old-refresh')
|
||||
localStorage.setItem('token_expires_at', overrides.token_expires_at || String(Date.now() - 1))
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 7, email: 'admin@example.com' }))
|
||||
}
|
||||
|
||||
function refreshedResponse() {
|
||||
return {
|
||||
data: {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: {
|
||||
access_token: 'new-access',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('refreshAuthTokens', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mockedPost.mockReset()
|
||||
vi.resetModules()
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: undefined
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shares one refresh request between concurrent callers in the same document', async () => {
|
||||
seedSession()
|
||||
let resolveRequest!: (value: ReturnType<typeof refreshedResponse>) => void
|
||||
mockedPost.mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
)
|
||||
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
|
||||
|
||||
const first = refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
const second = refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledTimes(1)
|
||||
resolveRequest(refreshedResponse())
|
||||
|
||||
await expect(first).resolves.toMatchObject({ access_token: 'new-access' })
|
||||
await expect(second).resolves.toMatchObject({ refresh_token: 'new-refresh' })
|
||||
expect(localStorage.getItem('refresh_token')).toBe('new-refresh')
|
||||
})
|
||||
|
||||
it('adopts tokens refreshed by another tab after acquiring the Web Lock', async () => {
|
||||
seedSession()
|
||||
const request = vi.fn(async (_name: string, callback: () => Promise<unknown>) => {
|
||||
localStorage.setItem('auth_token', 'peer-access')
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('refresh_token', 'peer-refresh')
|
||||
return callback()
|
||||
})
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: { request }
|
||||
})
|
||||
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
|
||||
|
||||
const result = await refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(mockedPost).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({
|
||||
access_token: 'peer-access',
|
||||
refresh_token: 'peer-refresh'
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers when a peer publishes the rotated token just after this request fails', async () => {
|
||||
seedSession()
|
||||
mockedPost.mockRejectedValueOnce(new Error('refresh token already used'))
|
||||
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
|
||||
|
||||
window.setTimeout(() => {
|
||||
localStorage.setItem('auth_token', 'peer-access')
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('refresh_token', 'peer-refresh')
|
||||
}, 10)
|
||||
|
||||
await expect(
|
||||
refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
).resolves.toMatchObject({
|
||||
access_token: 'peer-access',
|
||||
refresh_token: 'peer-refresh'
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for a slow peer after losing a refresh-token race without Web Locks', async () => {
|
||||
vi.useFakeTimers()
|
||||
seedSession()
|
||||
let resolveWinningRequest!: (value: ReturnType<typeof refreshedResponse>) => void
|
||||
mockedPost.mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveWinningRequest = resolve
|
||||
})
|
||||
)
|
||||
const firstTab = await import('@/api/tokenRefresh')
|
||||
vi.resetModules()
|
||||
const secondTab = await import('@/api/tokenRefresh')
|
||||
|
||||
const winner = firstTab.refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
mockedPost.mockRejectedValueOnce({ response: { status: 401 } })
|
||||
const loser = secondTab.refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
|
||||
window.setTimeout(() => resolveWinningRequest(refreshedResponse()), 1_500)
|
||||
await vi.advanceTimersByTimeAsync(1_600)
|
||||
|
||||
await expect(winner).resolves.toMatchObject({ access_token: 'new-access' })
|
||||
await expect(loser).resolves.toMatchObject({ refresh_token: 'new-refresh' })
|
||||
expect(mockedPost).toHaveBeenCalledTimes(2)
|
||||
expect(localStorage.getItem('refresh_token')).toBe('new-refresh')
|
||||
})
|
||||
|
||||
it('does not adopt a token from a different signed-in user', async () => {
|
||||
vi.useFakeTimers()
|
||||
seedSession()
|
||||
mockedPost.mockRejectedValueOnce(new Error('refresh token already used'))
|
||||
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
|
||||
|
||||
window.setTimeout(() => {
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 8, email: 'other@example.com' }))
|
||||
localStorage.setItem('auth_token', 'other-access')
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('refresh_token', 'other-refresh')
|
||||
}, 10)
|
||||
|
||||
const rejection = expect(
|
||||
refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
).rejects.toThrow('refresh token already used')
|
||||
await vi.advanceTimersByTimeAsync(1_100)
|
||||
await rejection
|
||||
})
|
||||
|
||||
it('does not restore a session that was logged out while refresh was in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
seedSession()
|
||||
let resolveRequest!: (value: ReturnType<typeof refreshedResponse>) => void
|
||||
mockedPost.mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
)
|
||||
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
|
||||
|
||||
const pending = refreshAuthTokens({ failedAccessToken: 'old-access' })
|
||||
localStorage.clear()
|
||||
resolveRequest(refreshedResponse())
|
||||
|
||||
const rejection = expect(pending).rejects.toThrow('Session changed during token refresh')
|
||||
await vi.advanceTimersByTimeAsync(1_100)
|
||||
await rejection
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('user api oauth binding urls', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/api/v1')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('builds third-party bind urls against the bind start endpoint', async () => {
|
||||
const { buildOAuthBindingStartURL } = await import('@/api/user')
|
||||
|
||||
expect(buildOAuthBindingStartURL('linuxdo', { redirectTo: '/settings/profile' })).toBe(
|
||||
'https://api.example.com/api/v1/auth/oauth/linuxdo/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user'
|
||||
)
|
||||
expect(
|
||||
buildOAuthBindingStartURL('wechat', {
|
||||
redirectTo: '/settings/profile',
|
||||
wechatOAuthSettings: {
|
||||
wechat_oauth_open_enabled: true,
|
||||
wechat_oauth_mp_enabled: false,
|
||||
wechat_oauth_mobile_enabled: false
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
'https://api.example.com/api/v1/auth/oauth/wechat/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user&mode=open'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Admin Affiliate API endpoints
|
||||
* Manage per-user affiliate (邀请返利) configurations:
|
||||
* exclusive invite codes (overrides aff_code) and exclusive rebate rates.
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { PaginatedResponse } from '@/types'
|
||||
|
||||
export interface AffiliateAdminEntry {
|
||||
user_id: number
|
||||
email: string
|
||||
username: string
|
||||
aff_code: string
|
||||
aff_code_custom: boolean
|
||||
aff_rebate_rate_percent?: number | null
|
||||
aff_count: number
|
||||
}
|
||||
|
||||
export interface ListAffiliateUsersParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface ListAffiliateRecordsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
start_at?: string
|
||||
end_at?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface AffiliateInviteRecord {
|
||||
inviter_id: number
|
||||
inviter_email: string
|
||||
inviter_username: string
|
||||
invitee_id: number
|
||||
invitee_email: string
|
||||
invitee_username: string
|
||||
aff_code: string
|
||||
total_rebate: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AffiliateRebateRecord {
|
||||
order_id: number
|
||||
out_trade_no: string
|
||||
inviter_id: number
|
||||
inviter_email: string
|
||||
inviter_username: string
|
||||
invitee_id: number
|
||||
invitee_email: string
|
||||
invitee_username: string
|
||||
order_amount: number
|
||||
pay_amount: number
|
||||
rebate_amount: number
|
||||
payment_type: string
|
||||
order_status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AffiliateTransferRecord {
|
||||
ledger_id: number
|
||||
user_id: number
|
||||
user_email: string
|
||||
username: string
|
||||
amount: number
|
||||
balance_after?: number | null
|
||||
available_quota_after?: number | null
|
||||
frozen_quota_after?: number | null
|
||||
history_quota_after?: number | null
|
||||
snapshot_available: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AffiliateUserOverview {
|
||||
user_id: number
|
||||
email: string
|
||||
username: string
|
||||
aff_code: string
|
||||
rebate_rate_percent: number
|
||||
invited_count: number
|
||||
rebated_invitee_count: number
|
||||
available_quota: number
|
||||
history_quota: number
|
||||
}
|
||||
|
||||
export interface UpdateAffiliateUserRequest {
|
||||
aff_code?: string
|
||||
aff_rebate_rate_percent?: number | null
|
||||
/** Set true to explicitly clear the per-user rate (sets it to NULL). */
|
||||
clear_rebate_rate?: boolean
|
||||
}
|
||||
|
||||
export interface BatchSetRateRequest {
|
||||
user_ids: number[]
|
||||
aff_rebate_rate_percent?: number | null
|
||||
/** Set true to clear rates instead of setting. */
|
||||
clear?: boolean
|
||||
}
|
||||
|
||||
export interface SimpleUser {
|
||||
id: number
|
||||
email: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
params: ListAffiliateUsersParams = {},
|
||||
): Promise<PaginatedResponse<AffiliateAdminEntry>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AffiliateAdminEntry>>(
|
||||
'/admin/affiliates/users',
|
||||
{
|
||||
params: {
|
||||
page: params.page ?? 1,
|
||||
page_size: params.page_size ?? 20,
|
||||
search: params.search ?? '',
|
||||
},
|
||||
},
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function lookupUsers(q: string): Promise<SimpleUser[]> {
|
||||
const { data } = await apiClient.get<SimpleUser[]>(
|
||||
'/admin/affiliates/users/lookup',
|
||||
{ params: { q } },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUserSettings(
|
||||
userId: number,
|
||||
payload: UpdateAffiliateUserRequest,
|
||||
): Promise<{ user_id: number }> {
|
||||
const { data } = await apiClient.put<{ user_id: number }>(
|
||||
`/admin/affiliates/users/${userId}`,
|
||||
payload,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function clearUserSettings(
|
||||
userId: number,
|
||||
): Promise<{ user_id: number }> {
|
||||
const { data } = await apiClient.delete<{ user_id: number }>(
|
||||
`/admin/affiliates/users/${userId}`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function batchSetRate(
|
||||
payload: BatchSetRateRequest,
|
||||
): Promise<{ affected: number }> {
|
||||
const { data } = await apiClient.post<{ affected: number }>(
|
||||
'/admin/affiliates/users/batch-rate',
|
||||
payload,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
function recordParams(params: ListAffiliateRecordsParams = {}) {
|
||||
return {
|
||||
page: params.page ?? 1,
|
||||
page_size: params.page_size ?? 20,
|
||||
search: params.search ?? '',
|
||||
start_at: params.start_at || undefined,
|
||||
end_at: params.end_at || undefined,
|
||||
sort_by: params.sort_by || undefined,
|
||||
sort_order: params.sort_order || undefined,
|
||||
timezone: params.timezone || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInviteRecords(
|
||||
params: ListAffiliateRecordsParams = {},
|
||||
): Promise<PaginatedResponse<AffiliateInviteRecord>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AffiliateInviteRecord>>(
|
||||
'/admin/affiliates/invites',
|
||||
{ params: recordParams(params) },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listRebateRecords(
|
||||
params: ListAffiliateRecordsParams = {},
|
||||
): Promise<PaginatedResponse<AffiliateRebateRecord>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AffiliateRebateRecord>>(
|
||||
'/admin/affiliates/rebates',
|
||||
{ params: recordParams(params) },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listTransferRecords(
|
||||
params: ListAffiliateRecordsParams = {},
|
||||
): Promise<PaginatedResponse<AffiliateTransferRecord>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AffiliateTransferRecord>>(
|
||||
'/admin/affiliates/transfers',
|
||||
{ params: recordParams(params) },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserOverview(
|
||||
userId: number,
|
||||
): Promise<AffiliateUserOverview> {
|
||||
const { data } = await apiClient.get<AffiliateUserOverview>(
|
||||
`/admin/affiliates/users/${userId}/overview`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const affiliatesAPI = {
|
||||
listUsers,
|
||||
lookupUsers,
|
||||
updateUserSettings,
|
||||
clearUserSettings,
|
||||
batchSetRate,
|
||||
listInviteRecords,
|
||||
listRebateRecords,
|
||||
listTransferRecords,
|
||||
getUserOverview,
|
||||
}
|
||||
|
||||
export default affiliatesAPI
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Admin Announcements API endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementUserReadStatus,
|
||||
BasePaginationResponse,
|
||||
CreateAnnouncementRequest,
|
||||
UpdateAnnouncementRequest
|
||||
} from '@/types'
|
||||
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<Announcement>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<Announcement> {
|
||||
const { data } = await apiClient.get<Announcement>(`/admin/announcements/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(request: CreateAnnouncementRequest): Promise<Announcement> {
|
||||
const { data } = await apiClient.post<Announcement>('/admin/announcements', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, request: UpdateAnnouncementRequest): Promise<Announcement> {
|
||||
const { data } = await apiClient.put<Announcement>(`/admin/announcements/${id}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/announcements/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getReadStatus(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
|
||||
`/admin/announcements/${id}/read-status`,
|
||||
{
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
const announcementsAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteAnnouncement,
|
||||
getReadStatus
|
||||
}
|
||||
|
||||
export default announcementsAPI
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Admin Antigravity API endpoints
|
||||
* Handles Antigravity (Google Cloud AI Companion) OAuth flows for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface AntigravityAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface AntigravityAuthUrlRequest {
|
||||
proxy_id?: number
|
||||
}
|
||||
|
||||
export interface AntigravityExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
code: string
|
||||
proxy_id?: number
|
||||
}
|
||||
|
||||
export interface AntigravityTokenInfo {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
expires_at?: number | string
|
||||
expires_in?: number
|
||||
project_id?: string
|
||||
email?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: AntigravityAuthUrlRequest
|
||||
): Promise<AntigravityAuthUrlResponse> {
|
||||
const { data } = await apiClient.post<AntigravityAuthUrlResponse>(
|
||||
'/admin/antigravity/oauth/auth-url',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
payload: AntigravityExchangeCodeRequest
|
||||
): Promise<AntigravityTokenInfo> {
|
||||
const { data } = await apiClient.post<AntigravityTokenInfo>(
|
||||
'/admin/antigravity/oauth/exchange-code',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function refreshAntigravityToken(
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<AntigravityTokenInfo> {
|
||||
const payload: Record<string, any> = { refresh_token: refreshToken }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
|
||||
const { data } = await apiClient.post<AntigravityTokenInfo>(
|
||||
'/admin/antigravity/oauth/refresh-token',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Admin API Keys API endpoints
|
||||
* Handles API key management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { ApiKey } from '@/types'
|
||||
|
||||
export interface UpdateApiKeyGroupResult {
|
||||
api_key: ApiKey
|
||||
auto_granted_group_access: boolean
|
||||
granted_group_id?: number
|
||||
granted_group_name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an API key's group binding
|
||||
* @param id - API Key ID
|
||||
* @param groupId - Group ID (0 to unbind, positive to bind, null/undefined to skip)
|
||||
* @returns Updated API key with auto-grant info
|
||||
*/
|
||||
export async function updateApiKeyGroup(id: number, groupId: number | null): Promise<UpdateApiKeyGroupResult> {
|
||||
const { data } = await apiClient.put<UpdateApiKeyGroupResult>(`/admin/api-keys/${id}`, {
|
||||
group_id: groupId === null ? 0 : groupId
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const apiKeysAPI = {
|
||||
updateApiKeyGroup
|
||||
}
|
||||
|
||||
export default apiKeysAPI
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Admin operation audit log API.
|
||||
*
|
||||
* The audit log is admin-only (not exposed to end users). It records
|
||||
* management-plane operations with masked header credentials and redacted
|
||||
* request bodies. Entries cannot be deleted individually; the whole log can
|
||||
* only be cleared with a fresh TOTP verification.
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { PaginatedResponse } from '@/types'
|
||||
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
created_at: string
|
||||
actor_user_id?: number
|
||||
actor_email: string
|
||||
actor_role: string
|
||||
auth_method: string
|
||||
credential_masked: string
|
||||
action: string
|
||||
method: string
|
||||
path: string
|
||||
request_id: string
|
||||
client_ip: string
|
||||
user_agent: string
|
||||
request_body?: string
|
||||
status_code: number
|
||||
latency_ms: number
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AuditLogQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
actor_user_id?: number
|
||||
actor_email?: string
|
||||
auth_method?: string
|
||||
action?: string
|
||||
method?: string
|
||||
client_ip?: string
|
||||
success?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
export type AuditLogListResponse = PaginatedResponse<AuditLog>
|
||||
|
||||
/**
|
||||
* List audit logs (paginated, filterable).
|
||||
*/
|
||||
export async function list(params: AuditLogQuery): Promise<AuditLogListResponse> {
|
||||
const { data } = await apiClient.get('/admin/audit-logs', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single audit log entry (includes the redacted request body).
|
||||
*/
|
||||
export async function get(id: number): Promise<AuditLog> {
|
||||
const { data } = await apiClient.get(`/admin/audit-logs/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all audit logs. Requires a fresh TOTP code (verified server-side);
|
||||
* unavailable when 2FA is not enabled for the operator.
|
||||
* @param totpCode - current 6-digit TOTP code
|
||||
*/
|
||||
export async function clear(totpCode: string): Promise<{ deleted: number }> {
|
||||
const { data } = await apiClient.post('/admin/audit-logs/clear', { totp_code: totpCode })
|
||||
return data
|
||||
}
|
||||
|
||||
export const auditAPI = {
|
||||
list,
|
||||
get,
|
||||
clear
|
||||
}
|
||||
|
||||
export default auditAPI
|
||||
@@ -0,0 +1,187 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface BackupS3Config {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
prefix: string
|
||||
force_path_style: boolean
|
||||
}
|
||||
|
||||
export interface BackupScheduleConfig {
|
||||
enabled: boolean
|
||||
cron_expr: string
|
||||
retain_days: number
|
||||
retain_count: number
|
||||
}
|
||||
|
||||
export interface BackupRecord {
|
||||
id: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
backup_type: string
|
||||
file_name: string
|
||||
s3_key: string
|
||||
parts?: BackupPart[]
|
||||
size_bytes: number
|
||||
triggered_by: string
|
||||
error_message?: string
|
||||
started_at: string
|
||||
finished_at?: string
|
||||
expires_at?: string
|
||||
progress?: string
|
||||
restore_status?: string
|
||||
restore_error?: string
|
||||
restored_at?: string
|
||||
}
|
||||
|
||||
export interface BackupPart {
|
||||
index: number
|
||||
s3_key: string
|
||||
size_bytes: number
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
export interface BackupDownloadPart {
|
||||
index: number
|
||||
size_bytes: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface BackupDownloadResponse {
|
||||
url?: string
|
||||
parts?: BackupDownloadPart[]
|
||||
}
|
||||
|
||||
export interface CreateBackupRequest {
|
||||
expire_days?: number
|
||||
}
|
||||
|
||||
export interface TestS3Response {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// S3 Config
|
||||
export async function getS3Config(): Promise<BackupS3Config> {
|
||||
const { data } = await apiClient.get<BackupS3Config>('/admin/backups/s3-config')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateS3Config(config: BackupS3Config): Promise<BackupS3Config> {
|
||||
const { data } = await apiClient.put<BackupS3Config>('/admin/backups/s3-config', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testS3Connection(config: BackupS3Config): Promise<TestS3Response> {
|
||||
const { data } = await apiClient.post<TestS3Response>('/admin/backups/s3-config/test', config)
|
||||
return data
|
||||
}
|
||||
|
||||
// Async image object storage
|
||||
//
|
||||
// Shares the S3 client with backups, so `reuse_backup_s3` borrows the endpoint and
|
||||
// credentials configured above and only keeps its own bucket/prefix.
|
||||
export interface ImageStorageConfig {
|
||||
enabled: boolean
|
||||
reuse_backup_s3: boolean
|
||||
bucket: string
|
||||
prefix: string
|
||||
public_base_url: string
|
||||
presign_expiry_hours: number
|
||||
max_download_bytes: number
|
||||
endpoint: string
|
||||
region: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
force_path_style: boolean
|
||||
}
|
||||
|
||||
export interface ImageStorageConfigResponse {
|
||||
config: ImageStorageConfig
|
||||
secret_configured: boolean
|
||||
}
|
||||
|
||||
export async function getImageStorageConfig(): Promise<ImageStorageConfigResponse> {
|
||||
const { data } = await apiClient.get<ImageStorageConfigResponse>('/admin/backups/image-storage')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateImageStorageConfig(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<ImageStorageConfig> {
|
||||
const { data } = await apiClient.put<ImageStorageConfig>('/admin/backups/image-storage', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testImageStorageConnection(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<TestS3Response> {
|
||||
const { data } = await apiClient.post<TestS3Response>(
|
||||
'/admin/backups/image-storage/test',
|
||||
config,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
// Schedule
|
||||
export async function getSchedule(): Promise<BackupScheduleConfig> {
|
||||
const { data } = await apiClient.get<BackupScheduleConfig>('/admin/backups/schedule')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateSchedule(config: BackupScheduleConfig): Promise<BackupScheduleConfig> {
|
||||
const { data } = await apiClient.put<BackupScheduleConfig>('/admin/backups/schedule', config)
|
||||
return data
|
||||
}
|
||||
|
||||
// Backup operations
|
||||
export async function createBackup(req?: CreateBackupRequest): Promise<BackupRecord> {
|
||||
const { data } = await apiClient.post<BackupRecord>('/admin/backups', req || {})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listBackups(): Promise<{ items: BackupRecord[] }> {
|
||||
const { data } = await apiClient.get<{ items: BackupRecord[] }>('/admin/backups')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getBackup(id: string): Promise<BackupRecord> {
|
||||
const { data } = await apiClient.get<BackupRecord>(`/admin/backups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteBackup(id: string): Promise<void> {
|
||||
await apiClient.delete(`/admin/backups/${id}`)
|
||||
}
|
||||
|
||||
export async function getDownloadURL(id: string): Promise<BackupDownloadResponse> {
|
||||
const { data } = await apiClient.get<BackupDownloadResponse>(`/admin/backups/${id}/download-url`)
|
||||
return data
|
||||
}
|
||||
|
||||
// Restore
|
||||
export async function restoreBackup(id: string, password: string): Promise<BackupRecord> {
|
||||
const { data } = await apiClient.post<BackupRecord>(`/admin/backups/${id}/restore`, { password })
|
||||
return data
|
||||
}
|
||||
|
||||
export const backupAPI = {
|
||||
getS3Config,
|
||||
updateS3Config,
|
||||
testS3Connection,
|
||||
getImageStorageConfig,
|
||||
updateImageStorageConfig,
|
||||
testImageStorageConnection,
|
||||
getSchedule,
|
||||
updateSchedule,
|
||||
createBackup,
|
||||
listBackups,
|
||||
getBackup,
|
||||
deleteBackup,
|
||||
getDownloadURL,
|
||||
restoreBackup,
|
||||
}
|
||||
|
||||
export default backupAPI
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Admin Channel Monitor API endpoints
|
||||
* Handles channel monitor (uptime/health) management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type Provider =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'gemini'
|
||||
| 'grok'
|
||||
| 'antigravity'
|
||||
| 'kimi'
|
||||
| 'zhipu'
|
||||
| 'deepseek'
|
||||
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
|
||||
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
|
||||
export type APIMode = 'chat_completions' | 'responses'
|
||||
/**
|
||||
* probe = LLM 探活(默认);quota = 仅查关联账号用量(零 LLM 成本);
|
||||
* quota_probe = 探活 + 配额快照挂主模型行。
|
||||
*/
|
||||
export type CheckMode = 'probe' | 'quota' | 'quota_probe'
|
||||
|
||||
/** 配额快照中的单个用量窗口(与后端 domain.MonitorQuotaTier 一致)。 */
|
||||
export interface MonitorQuotaTier {
|
||||
/** 5h | 7d | 7d-sonnet | 7d-fable | 30d | daily | weekly | total */
|
||||
window: string
|
||||
/** 同窗口多档时的机器标识(gemini shared/pro/flash、grok requests/tokens、antigravity 模型名) */
|
||||
label?: string
|
||||
used_percent: number
|
||||
used?: number
|
||||
limit?: number
|
||||
/** RFC3339;空表示无重置时间 */
|
||||
reset_at?: string
|
||||
}
|
||||
|
||||
export interface MonitorBalance {
|
||||
currency: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
/** 归一化配额快照(与后端 domain.MonitorQuotaSnapshot 一致)。 */
|
||||
export interface MonitorQuotaSnapshot {
|
||||
/** usage | cn_quota | cn_balance */
|
||||
source: string
|
||||
success: boolean
|
||||
tiers?: MonitorQuotaTier[]
|
||||
balance?: number | null
|
||||
balances?: MonitorBalance[]
|
||||
currency?: string
|
||||
plan_level?: string
|
||||
/** 401/403 鉴权失败标记(推导为 failed 状态) */
|
||||
credential_invalid?: boolean
|
||||
error?: string
|
||||
fetched_at: string
|
||||
}
|
||||
|
||||
export interface ChannelMonitor {
|
||||
id: number
|
||||
name: string
|
||||
provider: Provider
|
||||
api_mode: APIMode
|
||||
endpoint: string
|
||||
api_key_masked: string
|
||||
/**
|
||||
* True when the stored encrypted API key cannot be decrypted (e.g. the
|
||||
* encryption key has changed). Admin must re-edit the monitor to provide
|
||||
* a fresh key. Backend skips checks for these monitors.
|
||||
*/
|
||||
api_key_decrypt_failed?: boolean
|
||||
primary_model: string
|
||||
extra_models: string[]
|
||||
group_name: string
|
||||
enabled: boolean
|
||||
interval_seconds: number
|
||||
/** 每次调度在 interval 基础上 ± [0, jitter] 的随机偏移(秒),0 = 固定间隔 */
|
||||
jitter_seconds: number
|
||||
last_checked_at: string | null
|
||||
created_by: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
/** Latest status of the primary model (empty when no history yet) */
|
||||
primary_status: MonitorStatus | ''
|
||||
/** Latest latency of the primary model in ms (null when no history yet) */
|
||||
primary_latency_ms: number | null
|
||||
/** Primary model 7-day availability percentage (0-100) */
|
||||
availability_7d: number
|
||||
/** Latest status per extra model (used for hover tooltip) */
|
||||
extra_models_status: ExtraModelStatus[]
|
||||
/** 请求自定义快照字段(高级设置) */
|
||||
template_id: number | null
|
||||
extra_headers: Record<string, string>
|
||||
body_override_mode: BodyOverrideMode
|
||||
body_override: Record<string, unknown> | null
|
||||
/** 检测模式:probe(默认)/ quota / quota_probe */
|
||||
check_mode: CheckMode
|
||||
/** 配额模式关联的账号 ID;探活模式为 null */
|
||||
account_id: number | null
|
||||
/** 主模型最近一次配额快照(配额模式;无历史时为 null) */
|
||||
latest_quota?: MonitorQuotaSnapshot | null
|
||||
}
|
||||
|
||||
export interface ExtraModelStatus {
|
||||
model: string
|
||||
status: MonitorStatus | ''
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
provider?: Provider
|
||||
enabled?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
items: ChannelMonitor[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface CreateParams {
|
||||
name: string
|
||||
provider: Provider
|
||||
api_mode?: APIMode
|
||||
/** 探活模式必填(base origin);quota 模式可留空 */
|
||||
endpoint: string
|
||||
/** 探活模式必填;quota 模式可留空 */
|
||||
api_key: string
|
||||
/** 缺省 probe;antigravity 仅支持 quota */
|
||||
check_mode?: CheckMode
|
||||
/** 配额模式必填:数据源账号(provider 需与账号平台一致)。
|
||||
* update 语义:>0=换绑,0=解绑(切回 probe 模式时前端发 0 清空存量关联);
|
||||
* create 绝不发 0——后端会把 0 存成 &0 触发外键违约。 */
|
||||
account_id?: number | null
|
||||
primary_model: string
|
||||
extra_models?: string[]
|
||||
group_name?: string
|
||||
enabled?: boolean
|
||||
interval_seconds: number
|
||||
jitter_seconds?: number
|
||||
template_id?: number | null
|
||||
extra_headers?: Record<string, string>
|
||||
body_override_mode?: BodyOverrideMode
|
||||
body_override?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空;
|
||||
// account_id=0 显式解绑关联账号(null = 不动,见 CreateParams 注释)
|
||||
export type UpdateParams = Partial<CreateParams> & {
|
||||
clear_template?: boolean
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
model: string
|
||||
status: MonitorStatus
|
||||
latency_ms: number | null
|
||||
ping_latency_ms: number | null
|
||||
message: string
|
||||
checked_at: string
|
||||
/** 配额模式(quota / quota_probe 主模型行)附带的配额快照 */
|
||||
quota?: MonitorQuotaSnapshot | null
|
||||
}
|
||||
|
||||
export interface RunNowResponse {
|
||||
results: CheckResult[]
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
id: number
|
||||
model: string
|
||||
status: MonitorStatus
|
||||
latency_ms: number | null
|
||||
ping_latency_ms: number | null
|
||||
message: string
|
||||
checked_at: string
|
||||
/** 配额快照(配额模式行;探活行为空) */
|
||||
quota?: MonitorQuotaSnapshot | null
|
||||
}
|
||||
|
||||
export interface HistoryParams {
|
||||
model?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
items: HistoryItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* List channel monitors with pagination and filters
|
||||
*/
|
||||
export async function list(
|
||||
params: ListParams = {},
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<ListResponse> {
|
||||
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitors', {
|
||||
params,
|
||||
signal: options?.signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a channel monitor by ID
|
||||
*/
|
||||
export async function get(id: number): Promise<ChannelMonitor> {
|
||||
const { data } = await apiClient.get<ChannelMonitor>(`/admin/channel-monitors/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new channel monitor
|
||||
*/
|
||||
export async function create(params: CreateParams): Promise<ChannelMonitor> {
|
||||
const { data } = await apiClient.post<ChannelMonitor>('/admin/channel-monitors', params)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a monitor without exposing its stored API key to the browser.
|
||||
* Keep the operation key after ambiguous failures so a retry replays the
|
||||
* original server-side operation instead of creating another monitor.
|
||||
*/
|
||||
const duplicateOperationKeys = new Map<string, string>()
|
||||
|
||||
interface DuplicateOperationScope {
|
||||
adminID: string
|
||||
key: string
|
||||
}
|
||||
|
||||
function getCurrentAdminID(): string | null {
|
||||
try {
|
||||
const rawUser = globalThis.localStorage?.getItem('auth_user')
|
||||
if (!rawUser) return null
|
||||
|
||||
const user: unknown = JSON.parse(rawUser)
|
||||
if (typeof user !== 'object' || user === null) return null
|
||||
|
||||
const id = (user as { id?: unknown }).id
|
||||
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
|
||||
return String(id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
|
||||
const adminID = getCurrentAdminID()
|
||||
if (!adminID) return null
|
||||
|
||||
return {
|
||||
adminID,
|
||||
key: `sub2api:admin:channel-monitor-duplicate:${adminID}:${id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredDuplicateOperationKey(storageKey: string): string | null {
|
||||
try {
|
||||
return globalThis.sessionStorage?.getItem(storageKey) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
|
||||
try {
|
||||
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
|
||||
else globalThis.sessionStorage?.removeItem(storageKey)
|
||||
} catch {
|
||||
// In-memory retry protection still works when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicate(id: number): Promise<ChannelMonitor> {
|
||||
const scope = duplicateOperationScope(id)
|
||||
let idempotencyKey = scope
|
||||
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
|
||||
: null
|
||||
if (!idempotencyKey) {
|
||||
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
idempotencyKey = `channel-monitor-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
|
||||
}
|
||||
if (scope) {
|
||||
duplicateOperationKeys.set(scope.key, idempotencyKey)
|
||||
storeDuplicateOperationKey(scope.key, idempotencyKey)
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<ChannelMonitor>(
|
||||
`/admin/channel-monitors/${id}/duplicate`,
|
||||
undefined,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } }
|
||||
)
|
||||
|
||||
if (scope) {
|
||||
duplicateOperationKeys.delete(scope.key)
|
||||
storeDuplicateOperationKey(scope.key, null)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing channel monitor.
|
||||
* api_key field: empty string means "do not modify".
|
||||
*/
|
||||
export async function update(id: number, params: UpdateParams): Promise<ChannelMonitor> {
|
||||
const { data } = await apiClient.put<ChannelMonitor>(`/admin/channel-monitors/${id}`, params)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a channel monitor
|
||||
*/
|
||||
export async function del(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/channel-monitors/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate manual check for a channel monitor.
|
||||
* Returns the latest check results for primary + extra models.
|
||||
*/
|
||||
export async function runNow(id: number): Promise<RunNowResponse> {
|
||||
const { data } = await apiClient.post<RunNowResponse>(`/admin/channel-monitors/${id}/run`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List historical check results for a monitor.
|
||||
*/
|
||||
export async function listHistory(
|
||||
id: number,
|
||||
params: HistoryParams = {}
|
||||
): Promise<HistoryResponse> {
|
||||
const { data } = await apiClient.get<HistoryResponse>(
|
||||
`/admin/channel-monitors/${id}/history`,
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const channelMonitorAPI = {
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
duplicate,
|
||||
update,
|
||||
del,
|
||||
runNow,
|
||||
listHistory,
|
||||
}
|
||||
|
||||
export default channelMonitorAPI
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Admin Channel Monitor Request Template API.
|
||||
*
|
||||
* 模板 = 一组可复用的 headers + 可选 body 覆盖配置。
|
||||
* 应用到监控 = 拷贝快照;模板后续变动不自动同步,需手动点「应用到关联监控」刷新。
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { APIMode, BodyOverrideMode, Provider } from './channelMonitor'
|
||||
|
||||
export interface ChannelMonitorTemplate {
|
||||
id: number
|
||||
name: string
|
||||
provider: Provider
|
||||
api_mode: APIMode
|
||||
description: string
|
||||
extra_headers: Record<string, string>
|
||||
body_override_mode: BodyOverrideMode
|
||||
body_override: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
/** 关联的监控数量(快照来自此模板,仅 template_id 匹配即可) */
|
||||
associated_monitors: number
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
provider?: Provider
|
||||
api_mode?: APIMode
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
items: ChannelMonitorTemplate[]
|
||||
}
|
||||
|
||||
export interface CreateParams {
|
||||
name: string
|
||||
provider: Provider
|
||||
api_mode?: APIMode
|
||||
description?: string
|
||||
extra_headers?: Record<string, string>
|
||||
body_override_mode?: BodyOverrideMode
|
||||
body_override?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface UpdateParams {
|
||||
name?: string
|
||||
api_mode?: APIMode
|
||||
description?: string
|
||||
extra_headers?: Record<string, string>
|
||||
body_override_mode?: BodyOverrideMode
|
||||
body_override?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface ApplyResponse {
|
||||
affected: number
|
||||
}
|
||||
|
||||
export interface AssociatedMonitorBrief {
|
||||
id: number
|
||||
name: string
|
||||
provider: Provider
|
||||
api_mode: APIMode
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AssociatedMonitorsResponse {
|
||||
items: AssociatedMonitorBrief[]
|
||||
}
|
||||
|
||||
export async function list(params: ListParams = {}): Promise<ListResponse> {
|
||||
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitor-templates', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function get(id: number): Promise<ChannelMonitorTemplate> {
|
||||
const { data } = await apiClient.get<ChannelMonitorTemplate>(
|
||||
`/admin/channel-monitor-templates/${id}`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(params: CreateParams): Promise<ChannelMonitorTemplate> {
|
||||
const { data } = await apiClient.post<ChannelMonitorTemplate>(
|
||||
'/admin/channel-monitor-templates',
|
||||
params,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, params: UpdateParams): Promise<ChannelMonitorTemplate> {
|
||||
const { data } = await apiClient.put<ChannelMonitorTemplate>(
|
||||
`/admin/channel-monitor-templates/${id}`,
|
||||
params,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function del(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/channel-monitor-templates/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the template to the specified associated monitors (overwrite snapshot fields).
|
||||
* monitorIds must be a non-empty subset of the template's associated monitors.
|
||||
* Returns count of actually affected monitors.
|
||||
*/
|
||||
export async function apply(id: number, monitorIds: number[]): Promise<ApplyResponse> {
|
||||
const { data } = await apiClient.post<ApplyResponse>(
|
||||
`/admin/channel-monitor-templates/${id}/apply`,
|
||||
{ monitor_ids: monitorIds },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List monitors currently associated to this template (used by apply picker).
|
||||
*/
|
||||
export async function listAssociatedMonitors(id: number): Promise<AssociatedMonitorsResponse> {
|
||||
const { data } = await apiClient.get<AssociatedMonitorsResponse>(
|
||||
`/admin/channel-monitor-templates/${id}/monitors`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const channelMonitorTemplateAPI = {
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
update,
|
||||
del,
|
||||
apply,
|
||||
listAssociatedMonitors,
|
||||
}
|
||||
|
||||
export default channelMonitorTemplateAPI
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Admin Channels API endpoints
|
||||
* Handles channel management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { BillingMode, ChannelStatus, BillingModelSource } from '@/constants/channel'
|
||||
|
||||
export type { BillingMode } from '@/constants/channel'
|
||||
|
||||
export interface PricingInterval {
|
||||
id?: number
|
||||
min_tokens: number
|
||||
max_tokens: number | null
|
||||
tier_label: string
|
||||
input_price: number | null
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
input_multiplier: number | null
|
||||
output_multiplier: number | null
|
||||
cache_write_multiplier: number | null
|
||||
cache_read_multiplier: number | null
|
||||
per_request_price: number | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface ChannelTimePricingPeriod {
|
||||
start_time: string
|
||||
end_time: string
|
||||
multiplier: number
|
||||
}
|
||||
|
||||
export interface ChannelTimePricing {
|
||||
timezone: string
|
||||
periods: ChannelTimePricingPeriod[]
|
||||
}
|
||||
|
||||
export interface ChannelModelPricing {
|
||||
id?: number
|
||||
platform: string
|
||||
models: string[]
|
||||
billing_mode: BillingMode
|
||||
input_price: number | null
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
fast_multiplier?: number | null
|
||||
flex_multiplier?: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: PricingInterval[]
|
||||
time_pricing: ChannelTimePricing | null
|
||||
}
|
||||
|
||||
export interface AccountStatsPricingRule {
|
||||
id?: number
|
||||
name: string
|
||||
group_ids: number[]
|
||||
account_ids: number[]
|
||||
pricing: ChannelModelPricing[]
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
status: ChannelStatus
|
||||
billing_model_source: BillingModelSource
|
||||
restrict_models: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
group_ids: number[]
|
||||
model_pricing: ChannelModelPricing[]
|
||||
model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
|
||||
apply_pricing_to_account_stats: boolean
|
||||
account_stats_pricing_rules: AccountStatsPricingRule[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateChannelRequest {
|
||||
name: string
|
||||
description?: string
|
||||
group_ids?: number[]
|
||||
model_pricing?: ChannelModelPricing[]
|
||||
model_mapping?: Record<string, Record<string, string>>
|
||||
billing_model_source?: string
|
||||
restrict_models?: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
apply_pricing_to_account_stats?: boolean
|
||||
account_stats_pricing_rules?: AccountStatsPricingRule[]
|
||||
}
|
||||
|
||||
export interface UpdateChannelRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
status?: string
|
||||
group_ids?: number[]
|
||||
model_pricing?: ChannelModelPricing[]
|
||||
model_mapping?: Record<string, Record<string, string>>
|
||||
billing_model_source?: string
|
||||
restrict_models?: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
apply_pricing_to_account_stats?: boolean
|
||||
account_stats_pricing_rules?: AccountStatsPricingRule[]
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* List channels with pagination
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<Channel>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Channel>>('/admin/channels', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by ID
|
||||
*/
|
||||
export async function getById(id: number): Promise<Channel> {
|
||||
const { data } = await apiClient.get<Channel>(`/admin/channels/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new channel
|
||||
*/
|
||||
export async function create(req: CreateChannelRequest): Promise<Channel> {
|
||||
const { data } = await apiClient.post<Channel>('/admin/channels', req)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a channel
|
||||
*/
|
||||
export async function update(id: number, req: UpdateChannelRequest): Promise<Channel> {
|
||||
const { data } = await apiClient.put<Channel>(`/admin/channels/${id}`, req)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a channel
|
||||
*/
|
||||
export async function remove(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export interface ModelDefaultPricing {
|
||||
found: boolean
|
||||
input_price?: number // per-token price
|
||||
output_price?: number
|
||||
cache_write_price?: number
|
||||
cache_read_price?: number
|
||||
image_input_price?: number
|
||||
image_output_price?: number
|
||||
}
|
||||
|
||||
export async function getModelDefaultPricing(model: string): Promise<ModelDefaultPricing> {
|
||||
const { data } = await apiClient.get<ModelDefaultPricing>('/admin/channels/model-pricing', {
|
||||
params: { model }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface SyncPricingModelsResult {
|
||||
models: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest model names from the LiteLLM pricing catalog for the given platform
|
||||
*/
|
||||
export async function syncPricingModels(platform: string): Promise<SyncPricingModelsResult> {
|
||||
const { data } = await apiClient.get<SyncPricingModelsResult>('/admin/channels/pricing/sync-models', {
|
||||
params: { platform }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const channelsAPI = { list, getById, create, update, remove, getModelDefaultPricing, syncPricingModels }
|
||||
export default channelsAPI
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Admin CN providers (Kimi / Zhipu / DeepSeek) API endpoints.
|
||||
* Coding-plan rolling-window quota probe + payg balance probe.
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */
|
||||
export interface CNQuotaTier {
|
||||
window: '5h' | 'weekly'
|
||||
used_percent: number
|
||||
reset_at?: string
|
||||
}
|
||||
|
||||
/** Coding Plan 额度探测结果(kimi / zhipu),对齐后端 CNProviderQuotaProbeResult。 */
|
||||
export interface CNProviderQuotaProbeResult {
|
||||
provider: string
|
||||
source?: string
|
||||
success: boolean
|
||||
credential_valid: boolean
|
||||
tiers?: CNQuotaTier[]
|
||||
plan_level?: string
|
||||
status_code?: number
|
||||
fetched_at: number
|
||||
persisted: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 单币种余额明细(deepseek 双币种账号含 CNY + USD 两条)。 */
|
||||
export interface CNProviderBalanceEntry {
|
||||
currency: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
/** payg 余额探测结果(kimi / deepseek),对齐后端 CNProviderBalanceResult。 */
|
||||
export interface CNProviderBalanceResult {
|
||||
provider: string
|
||||
success: boolean
|
||||
/** 主币种余额(balances 首条,兼容单币种展示)。 */
|
||||
balance: number
|
||||
currency?: string
|
||||
/** 多币种明细;缺省时按主币种展示。 */
|
||||
balances?: CNProviderBalanceEntry[]
|
||||
available: boolean
|
||||
status_code?: number
|
||||
fetched_at: number
|
||||
persisted: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 查询 Coding Plan 滚动窗口用量(5h + weekly)。 */
|
||||
export async function queryQuota(id: number): Promise<CNProviderQuotaProbeResult> {
|
||||
const { data } = await apiClient.get<CNProviderQuotaProbeResult>(
|
||||
`/admin/cn-providers/accounts/${id}/quota`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 查询 payg 账号余额。 */
|
||||
export async function queryBalance(id: number): Promise<CNProviderBalanceResult> {
|
||||
const { data } = await apiClient.get<CNProviderBalanceResult>(
|
||||
`/admin/cn-providers/accounts/${id}/balance`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export default {
|
||||
queryQuota,
|
||||
queryBalance
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
export interface AdminComplianceAcknowledgement {
|
||||
version: string
|
||||
document_zh: string
|
||||
document_en: string
|
||||
admin_user_id: number
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
accepted_at: string
|
||||
}
|
||||
|
||||
export interface AdminComplianceStatus {
|
||||
required: boolean
|
||||
version: string
|
||||
document_path_zh: string
|
||||
document_path_en: string
|
||||
document_url_zh: string
|
||||
document_url_en: string
|
||||
ack_phrase_zh: string
|
||||
ack_phrase_en: string
|
||||
acknowledgement?: AdminComplianceAcknowledgement
|
||||
}
|
||||
|
||||
export interface AcceptAdminComplianceRequest {
|
||||
phrase: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export const adminComplianceAPI = {
|
||||
async getStatus(): Promise<AdminComplianceStatus> {
|
||||
const { data } = await apiClient.get<AdminComplianceStatus>('/admin/compliance')
|
||||
return data
|
||||
},
|
||||
|
||||
async accept(payload: AcceptAdminComplianceRequest): Promise<AdminComplianceStatus> {
|
||||
const { data } = await apiClient.post<AdminComplianceStatus>('/admin/compliance/accept', payload)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export default adminComplianceAPI
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Admin Dashboard API endpoints
|
||||
* Provides system-wide statistics and metrics
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
DashboardStats,
|
||||
TrendDataPoint,
|
||||
ModelStat,
|
||||
GroupStat,
|
||||
ApiKeyUsageTrendPoint,
|
||||
UserUsageTrendPoint,
|
||||
UserSpendingRankingResponse,
|
||||
UserBreakdownItem,
|
||||
UsageRequestType
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Get dashboard statistics
|
||||
* @returns Dashboard statistics including users, keys, accounts, and token usage
|
||||
*/
|
||||
export async function getStats(): Promise<DashboardStats> {
|
||||
const { data } = await apiClient.get<DashboardStats>('/admin/dashboard/stats')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time metrics
|
||||
* @returns Real-time system metrics
|
||||
*/
|
||||
export async function getRealtimeMetrics(): Promise<{
|
||||
active_requests: number
|
||||
requests_per_minute: number
|
||||
average_response_time: number
|
||||
error_rate: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
active_requests: number
|
||||
requests_per_minute: number
|
||||
average_response_time: number
|
||||
error_rate: number
|
||||
}>('/admin/dashboard/realtime')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface TrendParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
granularity?: 'day' | 'hour'
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
model?: string
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
upstream_model_mismatch?: boolean
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
trend: TrendDataPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage trend data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Usage trend data
|
||||
*/
|
||||
export async function getUsageTrend(params?: TrendParams): Promise<TrendResponse> {
|
||||
const { data } = await apiClient.get<TrendResponse>('/admin/dashboard/trend', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface ModelStatsParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
model?: string
|
||||
model_source?: 'requested' | 'upstream' | 'mapping'
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
upstream_model_mismatch?: boolean
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
models: ModelStat[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model usage statistics
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Model usage statistics
|
||||
*/
|
||||
export async function getModelStats(params?: ModelStatsParams): Promise<ModelStatsResponse> {
|
||||
const { data } = await apiClient.get<ModelStatsResponse>('/admin/dashboard/models', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface GroupStatsParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
upstream_model_mismatch?: boolean
|
||||
}
|
||||
|
||||
export interface GroupStatsResponse {
|
||||
groups: GroupStat[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export interface DashboardSnapshotV2Params extends TrendParams {
|
||||
include_stats?: boolean
|
||||
include_trend?: boolean
|
||||
include_model_stats?: boolean
|
||||
include_group_stats?: boolean
|
||||
include_users_trend?: boolean
|
||||
users_trend_limit?: number
|
||||
}
|
||||
|
||||
export interface DashboardSnapshotV2Stats extends DashboardStats {
|
||||
uptime: number
|
||||
}
|
||||
|
||||
export interface DashboardSnapshotV2Response {
|
||||
generated_at: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
stats?: DashboardSnapshotV2Stats
|
||||
trend?: TrendDataPoint[]
|
||||
models?: ModelStat[]
|
||||
groups?: GroupStat[]
|
||||
users_trend?: UserUsageTrendPoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group usage statistics
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Group usage statistics
|
||||
*/
|
||||
export async function getGroupStats(params?: GroupStatsParams): Promise<GroupStatsResponse> {
|
||||
const { data } = await apiClient.get<GroupStatsResponse>('/admin/dashboard/groups', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface UserBreakdownParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
group_id?: number
|
||||
model?: string
|
||||
model_source?: 'requested' | 'upstream' | 'mapping'
|
||||
endpoint?: string
|
||||
endpoint_type?: 'inbound' | 'upstream' | 'path'
|
||||
limit?: number
|
||||
// Sort column for the ranking (allowlisted server-side; falls back to actual_cost)
|
||||
sort_by?: 'total_tokens' | 'input_tokens' | 'output_tokens' | 'cache_tokens' | 'requests' | 'cost' | 'actual_cost'
|
||||
// Additional filter conditions
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UserBreakdownResponse {
|
||||
users: UserBreakdownItem[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export async function getUserBreakdown(params: UserBreakdownParams): Promise<UserBreakdownResponse> {
|
||||
const { data } = await apiClient.get<UserBreakdownResponse>('/admin/dashboard/user-breakdown', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
|
||||
*/
|
||||
export async function getSnapshotV2(params?: DashboardSnapshotV2Params): Promise<DashboardSnapshotV2Response> {
|
||||
const { data } = await apiClient.get<DashboardSnapshotV2Response>('/admin/dashboard/snapshot-v2', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface ApiKeyTrendParams extends TrendParams {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ApiKeyTrendResponse {
|
||||
trend: ApiKeyUsageTrendPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key usage trend data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns API key usage trend data
|
||||
*/
|
||||
export async function getApiKeyUsageTrend(
|
||||
params?: ApiKeyTrendParams
|
||||
): Promise<ApiKeyTrendResponse> {
|
||||
const { data } = await apiClient.get<ApiKeyTrendResponse>('/admin/dashboard/api-keys-trend', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface UserTrendParams extends TrendParams {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface UserTrendResponse {
|
||||
trend: UserUsageTrendPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
export interface UserSpendingRankingParams
|
||||
extends Pick<TrendParams, 'start_date' | 'end_date'> {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user usage trend data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns User usage trend data
|
||||
*/
|
||||
export async function getUserUsageTrend(params?: UserTrendParams): Promise<UserTrendResponse> {
|
||||
const { data } = await apiClient.get<UserTrendResponse>('/admin/dashboard/users-trend', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user spending ranking data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns User spending ranking data
|
||||
*/
|
||||
export async function getUserSpendingRanking(
|
||||
params?: UserSpendingRankingParams
|
||||
): Promise<UserSpendingRankingResponse> {
|
||||
const { data } = await apiClient.get<UserSpendingRankingResponse>('/admin/dashboard/users-ranking', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface PlatformUsage {
|
||||
platform: string
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
}
|
||||
|
||||
export interface BatchUserUsageStats {
|
||||
user_id: number
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
by_platform?: PlatformUsage[]
|
||||
}
|
||||
|
||||
export interface BatchUsersUsageResponse {
|
||||
stats: Record<string, BatchUserUsageStats>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch usage stats for multiple users
|
||||
* @param userIds - Array of user IDs
|
||||
* @returns Usage stats map keyed by user ID
|
||||
*/
|
||||
export async function getBatchUsersUsage(userIds: number[]): Promise<BatchUsersUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchUsersUsageResponse>('/admin/dashboard/users-usage', {
|
||||
user_ids: userIds
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchApiKeyUsageStats {
|
||||
api_key_id: number
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
}
|
||||
|
||||
export interface BatchApiKeysUsageResponse {
|
||||
stats: Record<string, BatchApiKeyUsageStats>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch usage stats for multiple API keys
|
||||
* @param apiKeyIds - Array of API key IDs
|
||||
* @returns Usage stats map keyed by API key ID
|
||||
*/
|
||||
export async function getBatchApiKeysUsage(
|
||||
apiKeyIds: number[]
|
||||
): Promise<BatchApiKeysUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
|
||||
'/admin/dashboard/api-keys-usage',
|
||||
{
|
||||
api_key_ids: apiKeyIds
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const dashboardAPI = {
|
||||
getStats,
|
||||
getRealtimeMetrics,
|
||||
getUsageTrend,
|
||||
getModelStats,
|
||||
getGroupStats,
|
||||
getSnapshotV2,
|
||||
getApiKeyUsageTrend,
|
||||
getUserUsageTrend,
|
||||
getUserSpendingRanking,
|
||||
getBatchUsersUsage,
|
||||
getBatchApiKeysUsage
|
||||
}
|
||||
|
||||
export default dashboardAPI
|
||||
@@ -0,0 +1,332 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type BackupType = 'postgres' | 'redis' | 'full'
|
||||
export type BackupJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'partial_succeeded'
|
||||
|
||||
export interface BackupAgentInfo {
|
||||
status: string
|
||||
version: string
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface BackupAgentHealth {
|
||||
enabled: boolean
|
||||
reason: string
|
||||
socket_path: string
|
||||
agent?: BackupAgentInfo
|
||||
}
|
||||
|
||||
export interface DataManagementPostgresConfig {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password?: string
|
||||
password_configured?: boolean
|
||||
database: string
|
||||
ssl_mode: string
|
||||
container_name: string
|
||||
}
|
||||
|
||||
export interface DataManagementRedisConfig {
|
||||
addr: string
|
||||
username: string
|
||||
password?: string
|
||||
password_configured?: boolean
|
||||
db: number
|
||||
container_name: string
|
||||
}
|
||||
|
||||
export interface DataManagementS3Config {
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
secret_access_key_configured?: boolean
|
||||
prefix: string
|
||||
force_path_style: boolean
|
||||
use_ssl: boolean
|
||||
}
|
||||
|
||||
export interface DataManagementConfig {
|
||||
source_mode: 'direct' | 'docker_exec'
|
||||
backup_root: string
|
||||
sqlite_path?: string
|
||||
retention_days: number
|
||||
keep_last: number
|
||||
active_postgres_profile_id?: string
|
||||
active_redis_profile_id?: string
|
||||
active_s3_profile_id?: string
|
||||
postgres: DataManagementPostgresConfig
|
||||
redis: DataManagementRedisConfig
|
||||
s3: DataManagementS3Config
|
||||
}
|
||||
|
||||
export type SourceType = 'postgres' | 'redis'
|
||||
|
||||
export interface DataManagementSourceConfig {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password?: string
|
||||
database: string
|
||||
ssl_mode: string
|
||||
addr: string
|
||||
username: string
|
||||
db: number
|
||||
container_name: string
|
||||
}
|
||||
|
||||
export interface DataManagementSourceProfile {
|
||||
source_type: SourceType
|
||||
profile_id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
password_configured?: boolean
|
||||
config: DataManagementSourceConfig
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface TestS3Request {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
prefix?: string
|
||||
force_path_style?: boolean
|
||||
use_ssl?: boolean
|
||||
}
|
||||
|
||||
export interface TestS3Response {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CreateBackupJobRequest {
|
||||
backup_type: BackupType
|
||||
upload_to_s3?: boolean
|
||||
s3_profile_id?: string
|
||||
postgres_profile_id?: string
|
||||
redis_profile_id?: string
|
||||
idempotency_key?: string
|
||||
}
|
||||
|
||||
export interface CreateBackupJobResponse {
|
||||
job_id: string
|
||||
status: BackupJobStatus
|
||||
}
|
||||
|
||||
export interface BackupArtifactInfo {
|
||||
local_path: string
|
||||
size_bytes: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export interface BackupS3Info {
|
||||
bucket: string
|
||||
key: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface BackupJob {
|
||||
job_id: string
|
||||
backup_type: BackupType
|
||||
status: BackupJobStatus
|
||||
triggered_by: string
|
||||
s3_profile_id?: string
|
||||
postgres_profile_id?: string
|
||||
redis_profile_id?: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
error_message?: string
|
||||
artifact?: BackupArtifactInfo
|
||||
s3?: BackupS3Info
|
||||
}
|
||||
|
||||
export interface ListSourceProfilesResponse {
|
||||
items: DataManagementSourceProfile[]
|
||||
}
|
||||
|
||||
export interface CreateSourceProfileRequest {
|
||||
profile_id: string
|
||||
name: string
|
||||
config: DataManagementSourceConfig
|
||||
set_active?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSourceProfileRequest {
|
||||
name: string
|
||||
config: DataManagementSourceConfig
|
||||
}
|
||||
|
||||
export interface DataManagementS3Profile {
|
||||
profile_id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
s3: DataManagementS3Config
|
||||
secret_access_key_configured?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ListS3ProfilesResponse {
|
||||
items: DataManagementS3Profile[]
|
||||
}
|
||||
|
||||
export interface CreateS3ProfileRequest {
|
||||
profile_id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
prefix?: string
|
||||
force_path_style?: boolean
|
||||
use_ssl?: boolean
|
||||
set_active?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateS3ProfileRequest {
|
||||
name: string
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
prefix?: string
|
||||
force_path_style?: boolean
|
||||
use_ssl?: boolean
|
||||
}
|
||||
|
||||
export interface ListBackupJobsRequest {
|
||||
page_size?: number
|
||||
page_token?: string
|
||||
status?: BackupJobStatus
|
||||
backup_type?: BackupType
|
||||
}
|
||||
|
||||
export interface ListBackupJobsResponse {
|
||||
items: BackupJob[]
|
||||
next_page_token?: string
|
||||
}
|
||||
|
||||
export async function getAgentHealth(): Promise<BackupAgentHealth> {
|
||||
const { data } = await apiClient.get<BackupAgentHealth>('/admin/data-management/agent/health')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<DataManagementConfig> {
|
||||
const { data } = await apiClient.get<DataManagementConfig>('/admin/data-management/config')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateConfig(request: DataManagementConfig): Promise<DataManagementConfig> {
|
||||
const { data } = await apiClient.put<DataManagementConfig>('/admin/data-management/config', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testS3(request: TestS3Request): Promise<TestS3Response> {
|
||||
const { data } = await apiClient.post<TestS3Response>('/admin/data-management/s3/test', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listSourceProfiles(sourceType: SourceType): Promise<ListSourceProfilesResponse> {
|
||||
const { data } = await apiClient.get<ListSourceProfilesResponse>(`/admin/data-management/sources/${sourceType}/profiles`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createSourceProfile(sourceType: SourceType, request: CreateSourceProfileRequest): Promise<DataManagementSourceProfile> {
|
||||
const { data } = await apiClient.post<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateSourceProfile(sourceType: SourceType, profileID: string, request: UpdateSourceProfileRequest): Promise<DataManagementSourceProfile> {
|
||||
const { data } = await apiClient.put<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles/${profileID}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteSourceProfile(sourceType: SourceType, profileID: string): Promise<void> {
|
||||
await apiClient.delete(`/admin/data-management/sources/${sourceType}/profiles/${profileID}`)
|
||||
}
|
||||
|
||||
export async function setActiveSourceProfile(sourceType: SourceType, profileID: string): Promise<DataManagementSourceProfile> {
|
||||
const { data } = await apiClient.post<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles/${profileID}/activate`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listS3Profiles(): Promise<ListS3ProfilesResponse> {
|
||||
const { data } = await apiClient.get<ListS3ProfilesResponse>('/admin/data-management/s3/profiles')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createS3Profile(request: CreateS3ProfileRequest): Promise<DataManagementS3Profile> {
|
||||
const { data } = await apiClient.post<DataManagementS3Profile>('/admin/data-management/s3/profiles', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateS3Profile(profileID: string, request: UpdateS3ProfileRequest): Promise<DataManagementS3Profile> {
|
||||
const { data } = await apiClient.put<DataManagementS3Profile>(`/admin/data-management/s3/profiles/${profileID}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteS3Profile(profileID: string): Promise<void> {
|
||||
await apiClient.delete(`/admin/data-management/s3/profiles/${profileID}`)
|
||||
}
|
||||
|
||||
export async function setActiveS3Profile(profileID: string): Promise<DataManagementS3Profile> {
|
||||
const { data } = await apiClient.post<DataManagementS3Profile>(`/admin/data-management/s3/profiles/${profileID}/activate`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createBackupJob(request: CreateBackupJobRequest): Promise<CreateBackupJobResponse> {
|
||||
const headers = request.idempotency_key
|
||||
? { 'X-Idempotency-Key': request.idempotency_key }
|
||||
: undefined
|
||||
|
||||
const { data } = await apiClient.post<CreateBackupJobResponse>(
|
||||
'/admin/data-management/backups',
|
||||
request,
|
||||
{ headers }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listBackupJobs(request?: ListBackupJobsRequest): Promise<ListBackupJobsResponse> {
|
||||
const { data } = await apiClient.get<ListBackupJobsResponse>('/admin/data-management/backups', {
|
||||
params: request
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getBackupJob(jobID: string): Promise<BackupJob> {
|
||||
const { data } = await apiClient.get<BackupJob>(`/admin/data-management/backups/${jobID}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const dataManagementAPI = {
|
||||
getAgentHealth,
|
||||
getConfig,
|
||||
updateConfig,
|
||||
listSourceProfiles,
|
||||
createSourceProfile,
|
||||
updateSourceProfile,
|
||||
deleteSourceProfile,
|
||||
setActiveSourceProfile,
|
||||
testS3,
|
||||
listS3Profiles,
|
||||
createS3Profile,
|
||||
updateS3Profile,
|
||||
deleteS3Profile,
|
||||
setActiveS3Profile,
|
||||
createBackupJob,
|
||||
listBackupJobs,
|
||||
getBackupJob
|
||||
}
|
||||
|
||||
export default dataManagementAPI
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Admin Error Passthrough Rules API endpoints
|
||||
* Handles error passthrough rule management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/**
|
||||
* Error passthrough rule interface
|
||||
*/
|
||||
export interface ErrorPassthroughRule {
|
||||
id: number
|
||||
name: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
error_codes: number[]
|
||||
keywords: string[]
|
||||
match_mode: 'any' | 'all'
|
||||
platforms: string[]
|
||||
passthrough_code: boolean
|
||||
response_code: number | null
|
||||
passthrough_body: boolean
|
||||
custom_message: string | null
|
||||
skip_monitoring: boolean
|
||||
description: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule request
|
||||
*/
|
||||
export interface CreateRuleRequest {
|
||||
name: string
|
||||
enabled?: boolean
|
||||
priority?: number
|
||||
error_codes?: number[]
|
||||
keywords?: string[]
|
||||
match_mode?: 'any' | 'all'
|
||||
platforms?: string[]
|
||||
passthrough_code?: boolean
|
||||
response_code?: number | null
|
||||
passthrough_body?: boolean
|
||||
custom_message?: string | null
|
||||
skip_monitoring?: boolean
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rule request
|
||||
*/
|
||||
export interface UpdateRuleRequest {
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
priority?: number
|
||||
error_codes?: number[]
|
||||
keywords?: string[]
|
||||
match_mode?: 'any' | 'all'
|
||||
platforms?: string[]
|
||||
passthrough_code?: boolean
|
||||
response_code?: number | null
|
||||
passthrough_body?: boolean
|
||||
custom_message?: string | null
|
||||
skip_monitoring?: boolean
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* List all error passthrough rules
|
||||
* @returns List of all rules sorted by priority
|
||||
*/
|
||||
export async function list(): Promise<ErrorPassthroughRule[]> {
|
||||
const { data } = await apiClient.get<ErrorPassthroughRule[]>('/admin/error-passthrough-rules')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule by ID
|
||||
* @param id - Rule ID
|
||||
* @returns Rule details
|
||||
*/
|
||||
export async function getById(id: number): Promise<ErrorPassthroughRule> {
|
||||
const { data } = await apiClient.get<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new rule
|
||||
* @param ruleData - Rule data
|
||||
* @returns Created rule
|
||||
*/
|
||||
export async function create(ruleData: CreateRuleRequest): Promise<ErrorPassthroughRule> {
|
||||
const { data } = await apiClient.post<ErrorPassthroughRule>('/admin/error-passthrough-rules', ruleData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rule
|
||||
* @param id - Rule ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated rule
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateRuleRequest): Promise<ErrorPassthroughRule> {
|
||||
const { data } = await apiClient.put<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rule
|
||||
* @param id - Rule ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteRule(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/error-passthrough-rules/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle rule enabled status
|
||||
* @param id - Rule ID
|
||||
* @param enabled - New enabled status
|
||||
* @returns Updated rule
|
||||
*/
|
||||
export async function toggleEnabled(id: number, enabled: boolean): Promise<ErrorPassthroughRule> {
|
||||
return update(id, { enabled })
|
||||
}
|
||||
|
||||
export const errorPassthroughAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteRule,
|
||||
toggleEnabled
|
||||
}
|
||||
|
||||
export default errorPassthroughAPI
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Admin Gemini API endpoints
|
||||
* Handles Gemini OAuth flows for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface GeminiAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface GeminiOAuthCapabilities {
|
||||
ai_studio_oauth_enabled: boolean
|
||||
required_redirect_uris: string[]
|
||||
}
|
||||
|
||||
export interface GeminiAuthUrlRequest {
|
||||
proxy_id?: number
|
||||
project_id?: string
|
||||
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
|
||||
tier_id?: string
|
||||
}
|
||||
|
||||
export interface GeminiExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
code: string
|
||||
proxy_id?: number
|
||||
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
|
||||
tier_id?: string
|
||||
}
|
||||
|
||||
export type GeminiTokenInfo = {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
scope?: string
|
||||
expires_in?: number
|
||||
expires_at?: number
|
||||
project_id?: string
|
||||
oauth_type?: string
|
||||
tier_id?: string
|
||||
extra?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: GeminiAuthUrlRequest
|
||||
): Promise<GeminiAuthUrlResponse> {
|
||||
const { data } = await apiClient.post<GeminiAuthUrlResponse>(
|
||||
'/admin/gemini/oauth/auth-url',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeCode(payload: GeminiExchangeCodeRequest): Promise<GeminiTokenInfo> {
|
||||
const { data } = await apiClient.post<GeminiTokenInfo>(
|
||||
'/admin/gemini/oauth/exchange-code',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<GeminiOAuthCapabilities> {
|
||||
const { data } = await apiClient.get<GeminiOAuthCapabilities>('/admin/gemini/oauth/capabilities')
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, getCapabilities }
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Admin Grok/xAI API endpoints
|
||||
* Handles xAI OAuth flows for administrators.
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { GrokBillingSummary, GrokQuotaWindow, WindowStats } from '@/types'
|
||||
|
||||
export type { GrokBillingSummary, GrokQuotaWindow } from '@/types'
|
||||
|
||||
export interface GrokAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface GrokAuthUrlRequest {
|
||||
proxy_id?: number
|
||||
redirect_uri?: string
|
||||
}
|
||||
|
||||
export interface GrokOAuthCapabilities {
|
||||
password_auth_enabled: boolean
|
||||
}
|
||||
|
||||
const GROK_AUTHORIZATION_TIMEOUT_MS = 120_000
|
||||
|
||||
export async function getCapabilities(): Promise<GrokOAuthCapabilities> {
|
||||
const { data } = await apiClient.get<GrokOAuthCapabilities>('/admin/grok/oauth/capabilities')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface GrokExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
code: string
|
||||
proxy_id?: number
|
||||
redirect_uri?: string
|
||||
}
|
||||
|
||||
export interface GrokTokenInfo {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
id_token?: string
|
||||
expires_at?: number | string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
client_id?: string
|
||||
email?: string
|
||||
sub?: string
|
||||
team_id?: string
|
||||
subscription_tier?: string
|
||||
entitlement_status?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface GrokSSOToOAuthRequest {
|
||||
sso_tokens: string[]
|
||||
name?: string
|
||||
notes?: string | null
|
||||
proxy_id?: number | null
|
||||
group_ids?: number[]
|
||||
credentials?: Record<string, unknown>
|
||||
extra?: Record<string, unknown>
|
||||
concurrency?: number
|
||||
load_factor?: number
|
||||
priority?: number
|
||||
rate_multiplier?: number
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
}
|
||||
|
||||
export interface GrokSSOToOAuthItemResult {
|
||||
index: number
|
||||
name?: string
|
||||
email?: string
|
||||
account?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GrokSSOToOAuthResponse {
|
||||
created: GrokSSOToOAuthItemResult[]
|
||||
failed: GrokSSOToOAuthItemResult[]
|
||||
}
|
||||
|
||||
const GROK_SSO_IMPORT_CONCURRENCY = 3
|
||||
const GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS = 90_000
|
||||
const GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS = 90_000
|
||||
|
||||
export function getGrokSSOImportTimeout(keyCount: number): number {
|
||||
const batches = Math.ceil(Math.max(1, keyCount) / GROK_SSO_IMPORT_CONCURRENCY)
|
||||
return batches * GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS + GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS
|
||||
}
|
||||
|
||||
export interface GrokQuotaSnapshot {
|
||||
requests?: GrokQuotaWindow | null
|
||||
tokens?: GrokQuotaWindow | null
|
||||
retry_after_seconds?: number | null
|
||||
subscription_tier?: string
|
||||
entitlement_status?: string
|
||||
status_code?: number
|
||||
headers?: Record<string, string>
|
||||
headers_observed: boolean
|
||||
observation_source?: string
|
||||
last_probe_at?: string
|
||||
last_headers_seen_at?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GrokQuotaProbeResult {
|
||||
source: 'active_probe' | 'billing_probe' | 'hybrid_probe'
|
||||
model?: string
|
||||
billing?: GrokBillingSummary | null
|
||||
snapshot?: GrokQuotaSnapshot | null
|
||||
local_usage_24h?: WindowStats | null
|
||||
local_usage_7d?: WindowStats | null
|
||||
local_usage_monthly?: WindowStats | null
|
||||
status_code?: number
|
||||
headers_observed: boolean
|
||||
reset_supported: boolean
|
||||
fetched_at: number
|
||||
persisted?: boolean
|
||||
probe_error?: string
|
||||
}
|
||||
|
||||
export interface GrokQuotaResetResult {
|
||||
supported: boolean
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: GrokAuthUrlRequest
|
||||
): Promise<GrokAuthUrlResponse> {
|
||||
const { data } = await apiClient.post<GrokAuthUrlResponse>(
|
||||
'/admin/grok/oauth/auth-url',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeCode(payload: GrokExchangeCodeRequest): Promise<GrokTokenInfo> {
|
||||
const { data } = await apiClient.post<GrokTokenInfo>(
|
||||
'/admin/grok/oauth/exchange-code',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function refreshGrokToken(
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo> {
|
||||
const payload: Record<string, unknown> = { refresh_token: refreshToken }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
|
||||
const { data } = await apiClient.post<GrokTokenInfo>(
|
||||
'/admin/grok/oauth/refresh-token',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function queryQuota(id: number): Promise<GrokQuotaProbeResult> {
|
||||
const { data } = await apiClient.get<GrokQuotaProbeResult>(`/admin/grok/accounts/${id}/quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
|
||||
const { data } = await apiClient.post<GrokQuotaResetResult>(`/admin/grok/accounts/${id}/reset-quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<GrokSSOToOAuthResponse> {
|
||||
const { data } = await apiClient.post<GrokSSOToOAuthResponse>(
|
||||
'/admin/grok/sso-to-oauth',
|
||||
payload,
|
||||
{ timeout: getGrokSSOImportTimeout(payload.sso_tokens.length) }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Validate a browser SSO cookie and convert to Build OAuth tokens (no raw SSO stored). */
|
||||
export async function validateSSOToken(
|
||||
ssoToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo> {
|
||||
const payload: Record<string, unknown> = { sso_token: ssoToken }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/sso-token', payload, {
|
||||
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Password login → ephemeral SSO → Build OAuth.
|
||||
* Password is only sent over the wire for this call; never persist it in credentials.
|
||||
*/
|
||||
export async function authorizePassword(
|
||||
emailAndPassword: string,
|
||||
proxyId?: number | null
|
||||
): Promise<GrokTokenInfo> {
|
||||
// Format: email----password (password may contain dashes).
|
||||
const sep = '----'
|
||||
const idx = emailAndPassword.indexOf(sep)
|
||||
const email = (idx >= 0 ? emailAndPassword.slice(0, idx) : emailAndPassword).trim()
|
||||
const password = idx >= 0 ? emailAndPassword.slice(idx + sep.length) : ''
|
||||
const payload: Record<string, unknown> = { email, password }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
const { data } = await apiClient.post<GrokTokenInfo>('/admin/grok/oauth/password', payload, {
|
||||
timeout: GROK_AUTHORIZATION_TIMEOUT_MS
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export default {
|
||||
generateAuthUrl,
|
||||
getCapabilities,
|
||||
exchangeCode,
|
||||
refreshGrokToken,
|
||||
queryQuota,
|
||||
resetQuota,
|
||||
createFromSSO,
|
||||
validateSSOToken,
|
||||
authorizePassword,
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Admin Groups API endpoints
|
||||
* Handles API key group management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
AdminGroup,
|
||||
GroupPlatform,
|
||||
CompositeModelRoute,
|
||||
CompositeModelRouteInput,
|
||||
CompositeRoutePreviewRequest,
|
||||
CompositeRouteDecision,
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
export interface LiveCapability {
|
||||
supported: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List all groups with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters (platform, status, is_exclusive, search)
|
||||
* @returns Paginated list of groups
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
platform?: GroupPlatform
|
||||
status?: 'active' | 'inactive'
|
||||
is_exclusive?: boolean
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<AdminGroup>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminGroup>>('/admin/groups', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active groups (without pagination)
|
||||
* @param platform - Optional platform filter
|
||||
* @returns List of all active groups
|
||||
*/
|
||||
export async function getAll(platform?: GroupPlatform): Promise<AdminGroup[]> {
|
||||
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
|
||||
params: platform ? { platform } : undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ALL groups including disabled ones — used by the API Key group filter so
|
||||
* that admins can filter users whose keys are still bound to a now-disabled group.
|
||||
*/
|
||||
export async function getAllIncludingInactive(): Promise<AdminGroup[]> {
|
||||
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
|
||||
params: { include_inactive: true }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active groups by platform
|
||||
* @param platform - Platform to filter by
|
||||
* @returns List of groups for the specified platform
|
||||
*/
|
||||
export async function getByPlatform(platform: GroupPlatform): Promise<AdminGroup[]> {
|
||||
return getAll(platform)
|
||||
}
|
||||
|
||||
/** 获取当前 Sub2API 服务端的 Live 运行环境能力。 */
|
||||
export async function getLiveCapability(): Promise<LiveCapability> {
|
||||
const { data } = await apiClient.get<LiveCapability>('/admin/groups/live-capability')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group by ID
|
||||
* @param id - Group ID
|
||||
* @returns Group details
|
||||
*/
|
||||
export async function getById(id: number): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.get<AdminGroup>(`/admin/groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get candidate models for custom /v1/models list.
|
||||
* id=0 returns platform default models for create flow.
|
||||
*/
|
||||
export async function getModelsListCandidates(
|
||||
id: number,
|
||||
platform?: GroupPlatform
|
||||
): Promise<string[]> {
|
||||
const { data } = await apiClient.get<{ models: string[] }>(
|
||||
`/admin/groups/${id}/models-list-candidates`,
|
||||
{
|
||||
params: platform ? { platform } : undefined
|
||||
}
|
||||
)
|
||||
return data.models || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new group
|
||||
* @param groupData - Group data
|
||||
* @returns Created group
|
||||
*/
|
||||
export async function create(groupData: CreateGroupRequest): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.post<AdminGroup>('/admin/groups', groupData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a group on the server so configuration that is not present in the
|
||||
* list response is preserved. Keep the operation key after ambiguous failures
|
||||
* so a retry replays the original operation instead of creating another group.
|
||||
*/
|
||||
const duplicateOperationKeys = new Map<string, string>()
|
||||
|
||||
interface DuplicateOperationScope {
|
||||
adminID: string
|
||||
key: string
|
||||
}
|
||||
|
||||
function getCurrentAdminID(): string | null {
|
||||
try {
|
||||
const rawUser = globalThis.localStorage?.getItem('auth_user')
|
||||
if (!rawUser) return null
|
||||
|
||||
const user: unknown = JSON.parse(rawUser)
|
||||
if (typeof user !== 'object' || user === null) return null
|
||||
|
||||
const id = (user as { id?: unknown }).id
|
||||
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
|
||||
return String(id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
|
||||
const adminID = getCurrentAdminID()
|
||||
if (!adminID) return null
|
||||
|
||||
return {
|
||||
adminID,
|
||||
key: `sub2api:admin:group-duplicate:${adminID}:${id}`
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredDuplicateOperationKey(storageKey: string): string | null {
|
||||
try {
|
||||
return globalThis.sessionStorage?.getItem(storageKey) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
|
||||
try {
|
||||
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
|
||||
else globalThis.sessionStorage?.removeItem(storageKey)
|
||||
} catch {
|
||||
// In-memory retry protection still works when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicate(id: number): Promise<AdminGroup> {
|
||||
const scope = duplicateOperationScope(id)
|
||||
let idempotencyKey = scope
|
||||
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
|
||||
: null
|
||||
if (!idempotencyKey) {
|
||||
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
|
||||
}
|
||||
if (scope) {
|
||||
duplicateOperationKeys.set(scope.key, idempotencyKey)
|
||||
storeDuplicateOperationKey(scope.key, idempotencyKey)
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<AdminGroup>(`/admin/groups/${id}/duplicate`, undefined, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey }
|
||||
})
|
||||
|
||||
if (scope) {
|
||||
duplicateOperationKeys.delete(scope.key)
|
||||
storeDuplicateOperationKey(scope.key, null)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group
|
||||
* @param id - Group ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateGroupRequest): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.put<AdminGroup>(`/admin/groups/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete group
|
||||
* @param id - Group ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteGroup(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle group status
|
||||
* @param id - Group ID
|
||||
* @param status - New status
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<AdminGroup> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group statistics
|
||||
* @param id - Group ID
|
||||
* @returns Group usage statistics
|
||||
*/
|
||||
export async function getStats(id: number): Promise<{
|
||||
total_api_keys: number
|
||||
active_api_keys: number
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_api_keys: number
|
||||
active_api_keys: number
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
}>(`/admin/groups/${id}/stats`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API keys in a group
|
||||
* @param id - Group ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @returns Paginated list of API keys in the group
|
||||
*/
|
||||
export async function getGroupApiKeys(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/groups/${id}/api-keys`, {
|
||||
params: { page, page_size: pageSize }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listCompositeRoutes(id: number): Promise<CompositeModelRoute[]> {
|
||||
const { data } = await apiClient.get<CompositeModelRoute[]>(`/admin/groups/${id}/composite-routes`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createCompositeRoute(
|
||||
id: number,
|
||||
route: CompositeModelRouteInput
|
||||
): Promise<CompositeModelRoute> {
|
||||
const { data } = await apiClient.post<CompositeModelRoute>(
|
||||
`/admin/groups/${id}/composite-routes`,
|
||||
route
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateCompositeRoute(
|
||||
id: number,
|
||||
routeId: number,
|
||||
route: CompositeModelRouteInput
|
||||
): Promise<CompositeModelRoute> {
|
||||
const { data } = await apiClient.put<CompositeModelRoute>(
|
||||
`/admin/groups/${id}/composite-routes/${routeId}`,
|
||||
route
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteCompositeRoute(
|
||||
id: number,
|
||||
routeId: number
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(
|
||||
`/admin/groups/${id}/composite-routes/${routeId}`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function previewCompositeRoute(
|
||||
id: number,
|
||||
request: CompositeRoutePreviewRequest
|
||||
): Promise<CompositeRouteDecision> {
|
||||
const { data } = await apiClient.post<CompositeRouteDecision>(
|
||||
`/admin/groups/${id}/composite-routes/preview`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate multiplier entry for a user in a group
|
||||
*/
|
||||
export interface GroupRateMultiplierEntry {
|
||||
user_id: number
|
||||
user_name: string
|
||||
user_email: string
|
||||
user_notes: string
|
||||
user_status: string
|
||||
rate_multiplier?: number | null
|
||||
rpm_override?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate multipliers for users in a group
|
||||
* @param id - Group ID
|
||||
* @returns List of user rate multiplier entries
|
||||
*/
|
||||
export async function getGroupRateMultipliers(id: number): Promise<GroupRateMultiplierEntry[]> {
|
||||
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
|
||||
`/admin/groups/${id}/rate-multipliers`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group sort orders
|
||||
* @param updates - Array of { id, sort_order } objects
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function updateSortOrder(
|
||||
updates: Array<{ id: number; sort_order: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>('/admin/groups/sort-order', {
|
||||
updates
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rate multipliers for a group
|
||||
* @param id - Group ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function clearGroupRateMultipliers(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rate-multipliers`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set rate multipliers for users in a group
|
||||
* Only touches rate_multiplier column; preserves rpm_override on existing rows.
|
||||
*/
|
||||
export async function batchSetGroupRateMultipliers(
|
||||
id: number,
|
||||
entries: Array<{ user_id: number; rate_multiplier: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>(
|
||||
`/admin/groups/${id}/rate-multipliers`,
|
||||
{ entries }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* RPM override entry for a user in a group
|
||||
*/
|
||||
export interface GroupRPMOverrideEntry {
|
||||
user_id: number
|
||||
user_name: string
|
||||
user_email: string
|
||||
user_notes: string
|
||||
user_status: string
|
||||
rpm_override: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RPM overrides for users in a group (subset of rate-multipliers endpoint).
|
||||
*/
|
||||
export async function getGroupRPMOverrides(id: number): Promise<GroupRPMOverrideEntry[]> {
|
||||
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
|
||||
`/admin/groups/${id}/rate-multipliers`
|
||||
)
|
||||
return data
|
||||
.filter(e => e.rpm_override != null)
|
||||
.map(e => ({
|
||||
user_id: e.user_id,
|
||||
user_name: e.user_name,
|
||||
user_email: e.user_email,
|
||||
user_notes: e.user_notes,
|
||||
user_status: e.user_status,
|
||||
rpm_override: e.rpm_override as number
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set RPM overrides for users in a group.
|
||||
* Only touches rpm_override column; preserves rate_multiplier on existing rows.
|
||||
*/
|
||||
export async function batchSetGroupRPMOverrides(
|
||||
id: number,
|
||||
entries: Array<{ user_id: number; rpm_override: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>(
|
||||
`/admin/groups/${id}/rpm-overrides`,
|
||||
{ entries }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all RPM overrides for a group (preserves rate_multiplier).
|
||||
*/
|
||||
export async function clearGroupRPMOverrides(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rpm-overrides`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage summary (today + yesterday + cumulative cost) for all groups
|
||||
* @returns Array of group usage summaries
|
||||
*/
|
||||
export async function getUsageSummary(): Promise<
|
||||
{ group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[]
|
||||
> {
|
||||
const { data } = await apiClient.get<
|
||||
{ group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[]
|
||||
>('/admin/groups/usage-summary')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capacity summary (concurrency/sessions/RPM) for all active groups
|
||||
*/
|
||||
export async function getCapacitySummary(): Promise<
|
||||
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
|
||||
> {
|
||||
const { data } = await apiClient.get<
|
||||
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
|
||||
>('/admin/groups/capacity-summary')
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
getByPlatform,
|
||||
getAllIncludingInactive,
|
||||
getLiveCapability,
|
||||
getById,
|
||||
getModelsListCandidates,
|
||||
create,
|
||||
duplicate,
|
||||
update,
|
||||
delete: deleteGroup,
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys,
|
||||
listCompositeRoutes,
|
||||
createCompositeRoute,
|
||||
updateCompositeRoute,
|
||||
deleteCompositeRoute,
|
||||
previewCompositeRoute,
|
||||
getGroupRateMultipliers,
|
||||
clearGroupRateMultipliers,
|
||||
batchSetGroupRateMultipliers,
|
||||
getGroupRPMOverrides,
|
||||
clearGroupRPMOverrides,
|
||||
batchSetGroupRPMOverrides,
|
||||
updateSortOrder,
|
||||
getUsageSummary,
|
||||
getCapacitySummary
|
||||
}
|
||||
|
||||
export default groupsAPI
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Admin API barrel export
|
||||
* Centralized exports for all admin API modules
|
||||
*/
|
||||
|
||||
import dashboardAPI from './dashboard'
|
||||
import usersAPI from './users'
|
||||
import groupsAPI from './groups'
|
||||
import accountsAPI from './accounts'
|
||||
import proxiesAPI from './proxies'
|
||||
import redeemAPI from './redeem'
|
||||
import promoAPI from './promo'
|
||||
import announcementsAPI from './announcements'
|
||||
import settingsAPI from './settings'
|
||||
import systemAPI from './system'
|
||||
import subscriptionsAPI from './subscriptions'
|
||||
import usageAPI from './usage'
|
||||
import geminiAPI from './gemini'
|
||||
import antigravityAPI from './antigravity'
|
||||
import grokAPI from './grok'
|
||||
import cnProvidersAPI from './cnProviders'
|
||||
import userAttributesAPI from './userAttributes'
|
||||
import opsAPI from './ops'
|
||||
import errorPassthroughAPI from './errorPassthrough'
|
||||
import dataManagementAPI from './dataManagement'
|
||||
import apiKeysAPI from './apiKeys'
|
||||
import scheduledTestsAPI from './scheduledTests'
|
||||
import backupAPI from './backup'
|
||||
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
||||
import channelsAPI from './channels'
|
||||
import channelMonitorAPI from './channelMonitor'
|
||||
import channelMonitorTemplateAPI from './channelMonitorTemplate'
|
||||
import adminPaymentAPI from './payment'
|
||||
import affiliatesAPI from './affiliates'
|
||||
import riskControlAPI from './riskControl'
|
||||
import adminComplianceAPI from './compliance'
|
||||
import auditAPI from './audit'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
*/
|
||||
export const adminAPI = {
|
||||
dashboard: dashboardAPI,
|
||||
users: usersAPI,
|
||||
groups: groupsAPI,
|
||||
accounts: accountsAPI,
|
||||
proxies: proxiesAPI,
|
||||
redeem: redeemAPI,
|
||||
promo: promoAPI,
|
||||
announcements: announcementsAPI,
|
||||
settings: settingsAPI,
|
||||
system: systemAPI,
|
||||
subscriptions: subscriptionsAPI,
|
||||
usage: usageAPI,
|
||||
gemini: geminiAPI,
|
||||
antigravity: antigravityAPI,
|
||||
grok: grokAPI,
|
||||
cnProviders: cnProvidersAPI,
|
||||
userAttributes: userAttributesAPI,
|
||||
ops: opsAPI,
|
||||
errorPassthrough: errorPassthroughAPI,
|
||||
dataManagement: dataManagementAPI,
|
||||
apiKeys: apiKeysAPI,
|
||||
scheduledTests: scheduledTestsAPI,
|
||||
backup: backupAPI,
|
||||
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
|
||||
channels: channelsAPI,
|
||||
channelMonitor: channelMonitorAPI,
|
||||
channelMonitorTemplate: channelMonitorTemplateAPI,
|
||||
payment: adminPaymentAPI,
|
||||
affiliates: affiliatesAPI,
|
||||
riskControl: riskControlAPI,
|
||||
compliance: adminComplianceAPI,
|
||||
audit: auditAPI
|
||||
}
|
||||
|
||||
export {
|
||||
dashboardAPI,
|
||||
usersAPI,
|
||||
groupsAPI,
|
||||
accountsAPI,
|
||||
proxiesAPI,
|
||||
redeemAPI,
|
||||
promoAPI,
|
||||
announcementsAPI,
|
||||
settingsAPI,
|
||||
systemAPI,
|
||||
subscriptionsAPI,
|
||||
usageAPI,
|
||||
geminiAPI,
|
||||
antigravityAPI,
|
||||
grokAPI,
|
||||
cnProvidersAPI,
|
||||
userAttributesAPI,
|
||||
opsAPI,
|
||||
errorPassthroughAPI,
|
||||
dataManagementAPI,
|
||||
apiKeysAPI,
|
||||
scheduledTestsAPI,
|
||||
backupAPI,
|
||||
tlsFingerprintProfileAPI,
|
||||
channelsAPI,
|
||||
channelMonitorAPI,
|
||||
channelMonitorTemplateAPI,
|
||||
adminPaymentAPI,
|
||||
affiliatesAPI,
|
||||
riskControlAPI,
|
||||
adminComplianceAPI,
|
||||
auditAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
// Re-export types used by components
|
||||
export type { AuditLog, AuditLogQuery, AuditLogListResponse } from './audit'
|
||||
export type { BalanceHistoryItem } from './users'
|
||||
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
|
||||
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
|
||||
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
|
||||
export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl'
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Admin Payment API endpoints
|
||||
* Handles payment management operations for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
DashboardStats,
|
||||
PaymentOrder,
|
||||
PaymentChannel,
|
||||
SubscriptionPlan,
|
||||
ProviderInstance
|
||||
} from '@/types/payment'
|
||||
import type { BasePaginationResponse } from '@/types'
|
||||
|
||||
/** Admin-facing payment config returned by GET /admin/payment/config */
|
||||
export interface AdminPaymentConfig {
|
||||
enabled: boolean
|
||||
min_amount: number
|
||||
max_amount: number
|
||||
daily_limit: number
|
||||
order_timeout_minutes: number
|
||||
max_pending_orders: number
|
||||
enabled_payment_types: string[]
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
subscription_usd_to_cny_rate: number
|
||||
recharge_fee_rate: number
|
||||
load_balance_strategy: string
|
||||
product_name_prefix: string
|
||||
product_name_suffix: string
|
||||
help_image_url: string
|
||||
help_text: string
|
||||
}
|
||||
|
||||
/** Fields accepted by PUT /admin/payment/config (all optional via pointer semantics) */
|
||||
export interface UpdatePaymentConfigRequest {
|
||||
enabled?: boolean
|
||||
min_amount?: number
|
||||
max_amount?: number
|
||||
daily_limit?: number
|
||||
order_timeout_minutes?: number
|
||||
max_pending_orders?: number
|
||||
enabled_payment_types?: string[]
|
||||
balance_disabled?: boolean
|
||||
balance_recharge_multiplier?: number
|
||||
subscription_usd_to_cny_rate?: number
|
||||
recharge_fee_rate?: number
|
||||
load_balance_strategy?: string
|
||||
product_name_prefix?: string
|
||||
product_name_suffix?: string
|
||||
help_image_url?: string
|
||||
help_text?: string
|
||||
}
|
||||
|
||||
export interface RefundResult {
|
||||
success: boolean
|
||||
warning?: string
|
||||
require_force?: boolean
|
||||
balance_deducted?: number
|
||||
subscription_days_deducted?: number
|
||||
}
|
||||
|
||||
export const adminPaymentAPI = {
|
||||
// ==================== Config ====================
|
||||
|
||||
/** Get payment configuration (admin view) */
|
||||
getConfig() {
|
||||
return apiClient.get<AdminPaymentConfig>('/admin/payment/config')
|
||||
},
|
||||
|
||||
/** Update payment configuration */
|
||||
updateConfig(data: UpdatePaymentConfigRequest) {
|
||||
return apiClient.put('/admin/payment/config', data)
|
||||
},
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
|
||||
/** Get payment dashboard statistics */
|
||||
getDashboard(days?: number) {
|
||||
return apiClient.get<DashboardStats>('/admin/payment/dashboard', {
|
||||
params: days ? { days } : undefined
|
||||
})
|
||||
},
|
||||
|
||||
// ==================== Orders ====================
|
||||
|
||||
/** Get all orders (paginated, with filters) */
|
||||
getOrders(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
payment_type?: string
|
||||
user_id?: number
|
||||
keyword?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
order_type?: string
|
||||
}) {
|
||||
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/admin/payment/orders', { params })
|
||||
},
|
||||
|
||||
/** Get a specific order by ID */
|
||||
getOrder(id: number) {
|
||||
return apiClient.get<PaymentOrder>(`/admin/payment/orders/${id}`)
|
||||
},
|
||||
|
||||
/** Cancel an order (admin) */
|
||||
cancelOrder(id: number) {
|
||||
return apiClient.post(`/admin/payment/orders/${id}/cancel`)
|
||||
},
|
||||
|
||||
/** Retry recharge for a failed order */
|
||||
retryRecharge(id: number) {
|
||||
return apiClient.post(`/admin/payment/orders/${id}/retry`)
|
||||
},
|
||||
|
||||
/** Process a refund */
|
||||
refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean; force?: boolean }) {
|
||||
return apiClient.post<RefundResult>(`/admin/payment/orders/${id}/refund`, data)
|
||||
},
|
||||
|
||||
/** Query and finalize a pending refund */
|
||||
queryRefund(id: number) {
|
||||
return apiClient.post<RefundResult>(`/admin/payment/orders/${id}/refund/query`)
|
||||
},
|
||||
|
||||
// ==================== Channels ====================
|
||||
|
||||
/** Get all payment channels */
|
||||
getChannels() {
|
||||
return apiClient.get<PaymentChannel[]>('/admin/payment/channels')
|
||||
},
|
||||
|
||||
/** Create a payment channel */
|
||||
createChannel(data: Partial<PaymentChannel>) {
|
||||
return apiClient.post<PaymentChannel>('/admin/payment/channels', data)
|
||||
},
|
||||
|
||||
/** Update a payment channel */
|
||||
updateChannel(id: number, data: Partial<PaymentChannel>) {
|
||||
return apiClient.put<PaymentChannel>(`/admin/payment/channels/${id}`, data)
|
||||
},
|
||||
|
||||
/** Delete a payment channel */
|
||||
deleteChannel(id: number) {
|
||||
return apiClient.delete(`/admin/payment/channels/${id}`)
|
||||
},
|
||||
|
||||
// ==================== Subscription Plans ====================
|
||||
|
||||
/** Get all subscription plans */
|
||||
getPlans() {
|
||||
return apiClient.get<SubscriptionPlan[]>('/admin/payment/plans')
|
||||
},
|
||||
|
||||
/** Create a subscription plan */
|
||||
createPlan(data: Record<string, unknown>) {
|
||||
return apiClient.post<SubscriptionPlan>('/admin/payment/plans', data)
|
||||
},
|
||||
|
||||
/** Update a subscription plan */
|
||||
updatePlan(id: number, data: Record<string, unknown>) {
|
||||
return apiClient.put<SubscriptionPlan>(`/admin/payment/plans/${id}`, data)
|
||||
},
|
||||
|
||||
/** Delete a subscription plan */
|
||||
deletePlan(id: number) {
|
||||
return apiClient.delete(`/admin/payment/plans/${id}`)
|
||||
},
|
||||
|
||||
// ==================== Provider Instances ====================
|
||||
|
||||
/** Get all provider instances */
|
||||
getProviders() {
|
||||
return apiClient.get<ProviderInstance[]>('/admin/payment/providers')
|
||||
},
|
||||
|
||||
/** Create a provider instance */
|
||||
createProvider(data: Partial<ProviderInstance>) {
|
||||
return apiClient.post<ProviderInstance>('/admin/payment/providers', data)
|
||||
},
|
||||
|
||||
/** Update a provider instance */
|
||||
updateProvider(id: number, data: Partial<ProviderInstance>) {
|
||||
return apiClient.put<ProviderInstance>(`/admin/payment/providers/${id}`, data)
|
||||
},
|
||||
|
||||
/** Delete a provider instance */
|
||||
deleteProvider(id: number) {
|
||||
return apiClient.delete(`/admin/payment/providers/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default adminPaymentAPI
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Admin Promo Codes API endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
PromoCode,
|
||||
PromoCodeUsage,
|
||||
CreatePromoCodeRequest,
|
||||
UpdatePromoCodeRequest,
|
||||
BasePaginationResponse
|
||||
} from '@/types'
|
||||
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BasePaginationResponse<PromoCode>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<PromoCode>>('/admin/promo-codes', {
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<PromoCode> {
|
||||
const { data } = await apiClient.get<PromoCode>(`/admin/promo-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(request: CreatePromoCodeRequest): Promise<PromoCode> {
|
||||
const { data } = await apiClient.post<PromoCode>('/admin/promo-codes', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, request: UpdatePromoCodeRequest): Promise<PromoCode> {
|
||||
const { data } = await apiClient.put<PromoCode>(`/admin/promo-codes/${id}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/promo-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUsages(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<BasePaginationResponse<PromoCodeUsage>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<PromoCodeUsage>>(
|
||||
`/admin/promo-codes/${id}/usages`,
|
||||
{ params: { page, page_size: pageSize } }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
const promoAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteCode,
|
||||
getUsages
|
||||
}
|
||||
|
||||
export default promoAPI
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Admin Proxies API endpoints
|
||||
* Handles proxy server management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
Proxy,
|
||||
ProxyAccountSummary,
|
||||
ProxyQualityCheckResult,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse,
|
||||
AdminDataPayload,
|
||||
AdminDataImportResult
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all proxies with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters
|
||||
* @returns Paginated list of proxies
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive' | 'expired'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<Proxy>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active proxies (without pagination)
|
||||
* @returns List of all active proxies
|
||||
*/
|
||||
export async function getAll(): Promise<Proxy[]> {
|
||||
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active proxies with account count (sorted by creation time desc)
|
||||
* @returns List of all active proxies with account count
|
||||
*/
|
||||
export async function getAllWithCount(): Promise<Proxy[]> {
|
||||
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all', {
|
||||
params: { with_count: 'true' }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy by ID
|
||||
* @param id - Proxy ID
|
||||
* @returns Proxy details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Proxy> {
|
||||
const { data } = await apiClient.get<Proxy>(`/admin/proxies/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new proxy
|
||||
* @param proxyData - Proxy data
|
||||
* @returns Created proxy
|
||||
*/
|
||||
export async function create(proxyData: CreateProxyRequest): Promise<Proxy> {
|
||||
const { data } = await apiClient.post<Proxy>('/admin/proxies', proxyData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update proxy
|
||||
* @param id - Proxy ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated proxy
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateProxyRequest): Promise<Proxy> {
|
||||
const { data } = await apiClient.put<Proxy>(`/admin/proxies/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete proxy
|
||||
* @param id - Proxy ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteProxy(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/proxies/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle proxy status
|
||||
* @param id - Proxy ID
|
||||
* @param status - New status
|
||||
* @returns Updated proxy
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Proxy> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Test proxy connectivity
|
||||
* @param id - Proxy ID
|
||||
* @returns Test result with IP info
|
||||
*/
|
||||
export async function testProxy(id: number): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
latency_ms?: number
|
||||
ip_address?: string
|
||||
city?: string
|
||||
region?: string
|
||||
country?: string
|
||||
country_code?: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: boolean
|
||||
message: string
|
||||
latency_ms?: number
|
||||
ip_address?: string
|
||||
city?: string
|
||||
region?: string
|
||||
country?: string
|
||||
country_code?: string
|
||||
}>(`/admin/proxies/${id}/test`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check proxy quality across common AI targets
|
||||
* @param id - Proxy ID
|
||||
* @returns Quality check result
|
||||
*/
|
||||
export async function checkProxyQuality(id: number): Promise<ProxyQualityCheckResult> {
|
||||
const { data } = await apiClient.post<ProxyQualityCheckResult>(`/admin/proxies/${id}/quality-check`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy usage statistics
|
||||
* @param id - Proxy ID
|
||||
* @returns Proxy usage statistics
|
||||
*/
|
||||
export async function getStats(id: number): Promise<{
|
||||
total_accounts: number
|
||||
active_accounts: number
|
||||
total_requests: number
|
||||
success_rate: number
|
||||
average_latency: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_accounts: number
|
||||
active_accounts: number
|
||||
total_requests: number
|
||||
success_rate: number
|
||||
average_latency: number
|
||||
}>(`/admin/proxies/${id}/stats`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts using a proxy
|
||||
* @param id - Proxy ID
|
||||
* @returns List of accounts using the proxy
|
||||
*/
|
||||
export async function getProxyAccounts(id: number): Promise<ProxyAccountSummary[]> {
|
||||
const { data } = await apiClient.get<ProxyAccountSummary[]>(`/admin/proxies/${id}/accounts`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create proxies
|
||||
* @param proxies - Array of proxy data to create
|
||||
* @returns Creation result with count of created and skipped
|
||||
*/
|
||||
export async function batchCreate(
|
||||
proxies: Array<{
|
||||
protocol: string
|
||||
host: string
|
||||
port: number
|
||||
username?: string
|
||||
password?: string
|
||||
}>
|
||||
): Promise<{
|
||||
created: number
|
||||
skipped: number
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
created: number
|
||||
skipped: number
|
||||
}>('/admin/proxies/batch', { proxies })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function batchDelete(ids: number[]): Promise<{
|
||||
deleted_ids: number[]
|
||||
skipped: Array<{ id: number; reason: string }>
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
deleted_ids: number[]
|
||||
skipped: Array<{ id: number; reason: string }>
|
||||
}>('/admin/proxies/batch-delete', { ids })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exportData(options?: {
|
||||
ids?: number[]
|
||||
filters?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive' | 'expired'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
}): Promise<AdminDataPayload> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { protocol, status, search, sort_by, sort_order } = options.filters
|
||||
if (protocol) params.protocol = protocol
|
||||
if (status) params.status = status
|
||||
if (search) params.search = search
|
||||
if (sort_by) params.sort_by = sort_by
|
||||
if (sort_order) params.sort_order = sort_order
|
||||
}
|
||||
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function importData(payload: {
|
||||
data: AdminDataPayload
|
||||
}): Promise<AdminDataImportResult> {
|
||||
const { data } = await apiClient.post<AdminDataImportResult>('/admin/proxies/data', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
list,
|
||||
getAll,
|
||||
getAllWithCount,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteProxy,
|
||||
toggleStatus,
|
||||
testProxy,
|
||||
checkProxyQuality,
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate,
|
||||
batchDelete,
|
||||
exportData,
|
||||
importData
|
||||
}
|
||||
|
||||
export default proxiesAPI
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Admin Redeem Codes API endpoints
|
||||
* Handles redeem code generation and management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
RedeemCode,
|
||||
GenerateRedeemCodesRequest,
|
||||
BatchUpdateRedeemCodeFields,
|
||||
RedeemCodeType,
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all redeem codes with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters
|
||||
* @returns Paginated list of redeem codes
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
type?: RedeemCodeType
|
||||
status?: 'active' | 'used' | 'expired' | 'unused' | 'disabled'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<RedeemCode>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redeem code by ID
|
||||
* @param id - Redeem code ID
|
||||
* @returns Redeem code details
|
||||
*/
|
||||
export async function getById(id: number): Promise<RedeemCode> {
|
||||
const { data } = await apiClient.get<RedeemCode>(`/admin/redeem-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new redeem codes
|
||||
* @param count - Number of codes to generate
|
||||
* @param type - Type of redeem code
|
||||
* @param value - Value of the code
|
||||
* @param groupId - Group ID (required for subscription type)
|
||||
* @param validityDays - Validity days (for subscription type)
|
||||
* @param expiresInDays - Days before the code itself expires
|
||||
* @returns Array of generated redeem codes
|
||||
*/
|
||||
export async function generate(
|
||||
count: number,
|
||||
type: RedeemCodeType,
|
||||
value: number,
|
||||
groupId?: number | null,
|
||||
validityDays?: number,
|
||||
expiresInDays?: number | null
|
||||
): Promise<RedeemCode[]> {
|
||||
const payload: GenerateRedeemCodesRequest = {
|
||||
count,
|
||||
type,
|
||||
value
|
||||
}
|
||||
|
||||
// 订阅类型专用字段
|
||||
if (type === 'subscription') {
|
||||
payload.group_id = groupId
|
||||
if (validityDays && validityDays > 0) {
|
||||
payload.validity_days = validityDays
|
||||
}
|
||||
}
|
||||
if (expiresInDays && expiresInDays > 0) {
|
||||
payload.expires_in_days = expiresInDays
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete redeem code
|
||||
* @param id - Redeem code ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/redeem-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete redeem codes
|
||||
* @param ids - Array of redeem code IDs
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function batchDelete(ids: number[]): Promise<{
|
||||
deleted: number
|
||||
message: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
deleted: number
|
||||
message: string
|
||||
}>('/admin/redeem-codes/batch-delete', { ids })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update selected redeem code fields
|
||||
* @param ids - Array of redeem code IDs
|
||||
* @param fields - Field collection to update
|
||||
* @returns Updated count
|
||||
*/
|
||||
export async function batchUpdate(
|
||||
ids: number[],
|
||||
fields: BatchUpdateRedeemCodeFields
|
||||
): Promise<{
|
||||
updated: number
|
||||
message: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
updated: number
|
||||
message: string
|
||||
}>('/admin/redeem-codes/batch-update', { ids, fields })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire redeem code
|
||||
* @param id - Redeem code ID
|
||||
* @returns Updated redeem code
|
||||
*/
|
||||
export async function expire(id: number): Promise<RedeemCode> {
|
||||
const { data } = await apiClient.post<RedeemCode>(`/admin/redeem-codes/${id}/expire`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redeem code statistics
|
||||
* @returns Statistics about redeem codes
|
||||
*/
|
||||
export async function getStats(): Promise<{
|
||||
total_codes: number
|
||||
active_codes: number
|
||||
used_codes: number
|
||||
expired_codes: number
|
||||
total_value_distributed: number
|
||||
by_type: Record<RedeemCodeType, number>
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_codes: number
|
||||
active_codes: number
|
||||
used_codes: number
|
||||
expired_codes: number
|
||||
total_value_distributed: number
|
||||
by_type: Record<RedeemCodeType, number>
|
||||
}>('/admin/redeem-codes/stats')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Export redeem codes to CSV
|
||||
* @param filters - Optional filters
|
||||
* @returns CSV data as blob
|
||||
*/
|
||||
export async function exportCodes(filters?: {
|
||||
type?: RedeemCodeType
|
||||
status?: 'used' | 'expired' | 'unused' | 'disabled'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}): Promise<Blob> {
|
||||
const response = await apiClient.get('/admin/redeem-codes/export', {
|
||||
params: filters,
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const redeemAPI = {
|
||||
list,
|
||||
getById,
|
||||
generate,
|
||||
delete: deleteCode,
|
||||
batchDelete,
|
||||
batchUpdate,
|
||||
expire,
|
||||
getStats,
|
||||
exportCodes
|
||||
}
|
||||
|
||||
export default redeemAPI
|
||||
@@ -0,0 +1,300 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type ModerationMode = 'off' | 'observe' | 'pre_block'
|
||||
export type KeywordBlockingMode = 'keyword_only' | 'keyword_and_api' | 'api_only'
|
||||
export type ContentModerationModelFilterType = 'all' | 'include' | 'exclude'
|
||||
|
||||
export interface ContentModerationModelFilter {
|
||||
type: ContentModerationModelFilterType
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface ContentModerationConfig {
|
||||
enabled: boolean
|
||||
mode: ModerationMode
|
||||
base_url: string
|
||||
model: string
|
||||
proxy_id: number | null
|
||||
api_key_configured: boolean
|
||||
api_key_masked: string
|
||||
api_key_count: number
|
||||
api_key_masks: string[]
|
||||
api_key_statuses: ContentModerationAPIKeyStatus[]
|
||||
timeout_ms: number
|
||||
sample_rate: number
|
||||
all_groups: boolean
|
||||
group_ids: number[]
|
||||
record_non_hits: boolean
|
||||
thresholds: Record<string, number>
|
||||
worker_count: number
|
||||
queue_size: number
|
||||
block_status: number
|
||||
block_message: string
|
||||
email_on_hit: boolean
|
||||
auto_ban_enabled: boolean
|
||||
ban_threshold: number
|
||||
violation_window_hours: number
|
||||
retry_count: number
|
||||
hit_retention_days: number
|
||||
non_hit_retention_days: number
|
||||
pre_hash_check_enabled: boolean
|
||||
blocked_keywords: string[]
|
||||
keyword_blocking_mode: KeywordBlockingMode
|
||||
model_filter: ContentModerationModelFilter
|
||||
cyber_policy_exclude_from_ban_count: boolean
|
||||
}
|
||||
|
||||
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
|
||||
|
||||
export interface ContentModerationAPIKeyStatus {
|
||||
index: number
|
||||
key_hash: string
|
||||
masked: string
|
||||
status: ContentModerationAPIKeyStatusValue
|
||||
failure_count: number
|
||||
success_count: number
|
||||
last_error: string
|
||||
last_checked_at?: string
|
||||
frozen_until?: string
|
||||
last_latency_ms: number
|
||||
last_http_status: number
|
||||
last_tested: boolean
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
export interface TestContentModerationAPIKeysPayload {
|
||||
api_keys?: string[]
|
||||
base_url?: string
|
||||
model?: string
|
||||
timeout_ms?: number
|
||||
// null/undefined 沿用已保存配置的代理;0 强制直连;>0 指定代理
|
||||
proxy_id?: number
|
||||
prompt?: string
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
export interface TestContentModerationAPIKeysResponse {
|
||||
items: ContentModerationAPIKeyStatus[]
|
||||
audit_result?: ContentModerationTestAuditResult
|
||||
image_count: number
|
||||
}
|
||||
|
||||
export interface ContentModerationTestAuditResult {
|
||||
flagged: boolean
|
||||
highest_category: string
|
||||
highest_score: number
|
||||
composite_score: number
|
||||
category_scores: Record<string, number>
|
||||
thresholds: Record<string, number>
|
||||
}
|
||||
|
||||
export interface UpdateContentModerationConfig {
|
||||
enabled?: boolean
|
||||
mode?: ModerationMode
|
||||
base_url?: string
|
||||
model?: string
|
||||
// undefined 不修改;0 清除(直连);>0 指定代理
|
||||
proxy_id?: number
|
||||
api_key?: string
|
||||
api_keys?: string[]
|
||||
api_keys_mode?: 'append' | 'replace'
|
||||
delete_api_key_hashes?: string[]
|
||||
clear_api_key?: boolean
|
||||
timeout_ms?: number
|
||||
sample_rate?: number
|
||||
all_groups?: boolean
|
||||
group_ids?: number[]
|
||||
record_non_hits?: boolean
|
||||
thresholds?: Record<string, number>
|
||||
worker_count?: number
|
||||
queue_size?: number
|
||||
block_status?: number
|
||||
block_message?: string
|
||||
email_on_hit?: boolean
|
||||
auto_ban_enabled?: boolean
|
||||
ban_threshold?: number
|
||||
violation_window_hours?: number
|
||||
retry_count?: number
|
||||
hit_retention_days?: number
|
||||
non_hit_retention_days?: number
|
||||
pre_hash_check_enabled?: boolean
|
||||
blocked_keywords?: string[]
|
||||
keyword_blocking_mode?: KeywordBlockingMode
|
||||
model_filter?: ContentModerationModelFilter
|
||||
cyber_policy_exclude_from_ban_count?: boolean
|
||||
}
|
||||
|
||||
export interface ContentModerationRuntimeStatus {
|
||||
enabled: boolean
|
||||
risk_control_enabled: boolean
|
||||
mode: ModerationMode
|
||||
worker_count: number
|
||||
max_workers: number
|
||||
active_workers: number
|
||||
idle_workers: number
|
||||
queue_size: number
|
||||
queue_length: number
|
||||
queue_usage_percent: number
|
||||
enqueued: number
|
||||
dropped: number
|
||||
processed: number
|
||||
errors: number
|
||||
pre_block_active: number
|
||||
pre_block_checked: number
|
||||
pre_block_allowed: number
|
||||
pre_block_blocked: number
|
||||
pre_block_errors: number
|
||||
pre_block_avg_latency_ms: number
|
||||
pre_block_api_key_active: number
|
||||
pre_block_api_key_available_count: number
|
||||
pre_block_api_key_total_calls: number
|
||||
pre_block_api_key_loads: ContentModerationAPIKeyLoad[]
|
||||
api_key_statuses: ContentModerationAPIKeyStatus[]
|
||||
flagged_hash_count: number
|
||||
last_cleanup_at?: string
|
||||
last_cleanup_deleted_hit: number
|
||||
last_cleanup_deleted_non_hit: number
|
||||
}
|
||||
|
||||
export interface ContentModerationAPIKeyLoad {
|
||||
index: number
|
||||
key_hash: string
|
||||
masked: string
|
||||
status: ContentModerationAPIKeyStatusValue
|
||||
active: number
|
||||
total: number
|
||||
success: number
|
||||
errors: number
|
||||
avg_latency_ms: number
|
||||
last_latency_ms: number
|
||||
last_http_status: number
|
||||
}
|
||||
|
||||
export interface ContentModerationLog {
|
||||
id: number
|
||||
request_id: string
|
||||
user_id: number | null
|
||||
user_email: string
|
||||
api_key_id: number | null
|
||||
api_key_name: string
|
||||
group_id: number | null
|
||||
group_name: string
|
||||
endpoint: string
|
||||
provider: string
|
||||
model: string
|
||||
mode: string
|
||||
action: string
|
||||
flagged: boolean
|
||||
highest_category: string
|
||||
highest_score: number
|
||||
matched_keyword: string
|
||||
category_scores: Record<string, number>
|
||||
threshold_snapshot: Record<string, number>
|
||||
input_excerpt: string
|
||||
upstream_latency_ms: number | null
|
||||
error: string
|
||||
violation_count: number
|
||||
auto_banned: boolean
|
||||
email_sent: boolean
|
||||
user_status: string
|
||||
queue_delay_ms: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ListContentModerationLogsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
result?: string
|
||||
group_id?: number
|
||||
endpoint?: string
|
||||
search?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export interface ContentModerationLogsResponse {
|
||||
items: ContentModerationLog[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface ContentModerationUnbanUserResponse {
|
||||
user_id: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface DeleteFlaggedHashResponse {
|
||||
input_hash: string
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
export interface ClearFlaggedHashesResponse {
|
||||
deleted: number
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<ContentModerationConfig> {
|
||||
const { data } = await apiClient.get<ContentModerationConfig>('/admin/risk-control/config')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
payload: UpdateContentModerationConfig
|
||||
): Promise<ContentModerationConfig> {
|
||||
const { data } = await apiClient.put<ContentModerationConfig>('/admin/risk-control/config', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<ContentModerationRuntimeStatus> {
|
||||
const { data } = await apiClient.get<ContentModerationRuntimeStatus>('/admin/risk-control/status')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testAPIKeys(
|
||||
payload: TestContentModerationAPIKeysPayload = {}
|
||||
): Promise<TestContentModerationAPIKeysResponse> {
|
||||
const { data } = await apiClient.post<TestContentModerationAPIKeysResponse>('/admin/risk-control/api-keys/test', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listLogs(
|
||||
params: ListContentModerationLogsParams = {}
|
||||
): Promise<ContentModerationLogsResponse> {
|
||||
const { data } = await apiClient.get<ContentModerationLogsResponse>('/admin/risk-control/logs', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbanUser(userID: number): Promise<ContentModerationUnbanUserResponse> {
|
||||
const { data } = await apiClient.post<ContentModerationUnbanUserResponse>(
|
||||
`/admin/risk-control/users/${userID}/unban`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteFlaggedHash(inputHash: string): Promise<DeleteFlaggedHashResponse> {
|
||||
const { data } = await apiClient.delete<DeleteFlaggedHashResponse>('/admin/risk-control/hashes', {
|
||||
data: { input_hash: inputHash },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function clearFlaggedHashes(): Promise<ClearFlaggedHashesResponse> {
|
||||
const { data } = await apiClient.delete<ClearFlaggedHashesResponse>('/admin/risk-control/hashes/all')
|
||||
return data
|
||||
}
|
||||
|
||||
export const riskControlAPI = {
|
||||
getConfig,
|
||||
updateConfig,
|
||||
getStatus,
|
||||
testAPIKeys,
|
||||
listLogs,
|
||||
unbanUser,
|
||||
deleteFlaggedHash,
|
||||
clearFlaggedHashes,
|
||||
}
|
||||
|
||||
export default riskControlAPI
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Admin Scheduled Tests API endpoints
|
||||
* Handles scheduled test plan management for account connectivity monitoring
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
ScheduledTestPlan,
|
||||
ScheduledTestResult,
|
||||
CreateScheduledTestPlanRequest,
|
||||
UpdateScheduledTestPlanRequest
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all scheduled test plans for an account
|
||||
* @param accountId - Account ID
|
||||
* @returns List of scheduled test plans
|
||||
*/
|
||||
export async function listByAccount(accountId: number): Promise<ScheduledTestPlan[]> {
|
||||
const { data } = await apiClient.get<ScheduledTestPlan[]>(
|
||||
`/admin/accounts/${accountId}/scheduled-test-plans`
|
||||
)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new scheduled test plan
|
||||
* @param req - Plan creation request
|
||||
* @returns Created plan
|
||||
*/
|
||||
export async function create(req: CreateScheduledTestPlanRequest): Promise<ScheduledTestPlan> {
|
||||
const { data } = await apiClient.post<ScheduledTestPlan>(
|
||||
'/admin/scheduled-test-plans',
|
||||
req
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing scheduled test plan
|
||||
* @param id - Plan ID
|
||||
* @param req - Fields to update
|
||||
* @returns Updated plan
|
||||
*/
|
||||
export async function update(id: number, req: UpdateScheduledTestPlanRequest): Promise<ScheduledTestPlan> {
|
||||
const { data } = await apiClient.put<ScheduledTestPlan>(
|
||||
`/admin/scheduled-test-plans/${id}`,
|
||||
req
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a scheduled test plan
|
||||
* @param id - Plan ID
|
||||
*/
|
||||
export async function deletePlan(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/scheduled-test-plans/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* List test results for a plan
|
||||
* @param planId - Plan ID
|
||||
* @param limit - Optional max number of results to return
|
||||
* @returns List of test results
|
||||
*/
|
||||
export async function listResults(planId: number, limit?: number): Promise<ScheduledTestResult[]> {
|
||||
const { data } = await apiClient.get<ScheduledTestResult[]>(
|
||||
`/admin/scheduled-test-plans/${planId}/results`,
|
||||
{
|
||||
params: limit ? { limit } : undefined
|
||||
}
|
||||
)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export const scheduledTestsAPI = {
|
||||
listByAccount,
|
||||
create,
|
||||
update,
|
||||
delete: deletePlan,
|
||||
listResults
|
||||
}
|
||||
|
||||
export default scheduledTestsAPI
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Admin Subscriptions API endpoints
|
||||
* Handles user subscription management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
UserSubscription,
|
||||
SubscriptionProgress,
|
||||
AssignSubscriptionRequest,
|
||||
BulkAssignSubscriptionRequest,
|
||||
ExtendSubscriptionRequest,
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all subscriptions with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters (status, user_id, group_id, sort_by, sort_order)
|
||||
* @returns Paginated list of subscriptions
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: 'active' | 'expired' | 'revoked' | 'suspended'
|
||||
user_id?: number
|
||||
group_id?: number
|
||||
platform?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<UserSubscription>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
'/admin/subscriptions',
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
signal: options?.signal
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription by ID
|
||||
* @param id - Subscription ID
|
||||
* @returns Subscription details
|
||||
*/
|
||||
export async function getById(id: number): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.get<UserSubscription>(`/admin/subscriptions/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription progress
|
||||
* @param id - Subscription ID
|
||||
* @returns Subscription progress with usage stats
|
||||
*/
|
||||
export async function getProgress(id: number): Promise<SubscriptionProgress> {
|
||||
const { data } = await apiClient.get<SubscriptionProgress>(`/admin/subscriptions/${id}/progress`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign subscription to user
|
||||
* @param request - Assignment request
|
||||
* @returns Created subscription
|
||||
*/
|
||||
export async function assign(request: AssignSubscriptionRequest): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>('/admin/subscriptions/assign', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk assign subscriptions to multiple users
|
||||
* @param request - Bulk assignment request
|
||||
* @returns Created subscriptions
|
||||
*/
|
||||
export async function bulkAssign(
|
||||
request: BulkAssignSubscriptionRequest
|
||||
): Promise<UserSubscription[]> {
|
||||
const { data } = await apiClient.post<UserSubscription[]>(
|
||||
'/admin/subscriptions/bulk-assign',
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend subscription validity
|
||||
* @param id - Subscription ID
|
||||
* @param request - Extension request with days
|
||||
* @returns Updated subscription
|
||||
*/
|
||||
export async function extend(
|
||||
id: number,
|
||||
request: ExtendSubscriptionRequest
|
||||
): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>(
|
||||
`/admin/subscriptions/${id}/extend`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke subscription
|
||||
* @param id - Subscription ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function revoke(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>(`/admin/subscriptions/${id}/revoke`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore revoked subscription
|
||||
* @param id - Subscription ID
|
||||
* @returns Restored subscription
|
||||
*/
|
||||
export async function restore(id: number): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>(`/admin/subscriptions/${id}/restore`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset daily, weekly, and/or monthly usage quota for a subscription
|
||||
* @param id - Subscription ID
|
||||
* @param options - Which windows to reset
|
||||
* @returns Updated subscription
|
||||
*/
|
||||
export async function resetQuota(
|
||||
id: number,
|
||||
options: { daily: boolean; weekly: boolean; monthly: boolean }
|
||||
): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>(
|
||||
`/admin/subscriptions/${id}/reset-quota`,
|
||||
options
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List subscriptions by group
|
||||
* @param groupId - Group ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @returns Paginated list of subscriptions in the group
|
||||
*/
|
||||
export async function listByGroup(
|
||||
groupId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<PaginatedResponse<UserSubscription>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
`/admin/groups/${groupId}/subscriptions`,
|
||||
{
|
||||
params: { page, page_size: pageSize }
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List subscriptions by user
|
||||
* @param userId - User ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @returns Paginated list of user's subscriptions
|
||||
*/
|
||||
export async function listByUser(
|
||||
userId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<PaginatedResponse<UserSubscription>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
`/admin/users/${userId}/subscriptions`,
|
||||
{
|
||||
params: { page, page_size: pageSize }
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const subscriptionsAPI = {
|
||||
list,
|
||||
getById,
|
||||
getProgress,
|
||||
assign,
|
||||
bulkAssign,
|
||||
extend,
|
||||
revoke,
|
||||
restore,
|
||||
resetQuota,
|
||||
listByGroup,
|
||||
listByUser
|
||||
}
|
||||
|
||||
export default subscriptionsAPI
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* System API endpoints for admin operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface ReleaseInfo {
|
||||
name: string
|
||||
body: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
current_version: string
|
||||
latest_version: string
|
||||
has_update: boolean
|
||||
release_info?: ReleaseInfo
|
||||
cached: boolean
|
||||
warning?: string
|
||||
build_type: string // "source" for manual builds, "release" for CI builds
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version
|
||||
*/
|
||||
export async function getVersion(): Promise<{ version: string }> {
|
||||
const { data } = await apiClient.get<{ version: string }>('/admin/system/version')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates
|
||||
* @param force - Force refresh from GitHub API
|
||||
*/
|
||||
export async function checkUpdates(force = false): Promise<VersionInfo> {
|
||||
const { data } = await apiClient.get<VersionInfo>('/admin/system/check-updates', {
|
||||
params: force ? { force: 'true' } : undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
message: string
|
||||
need_restart: boolean
|
||||
}
|
||||
|
||||
export interface RollbackVersionInfo {
|
||||
version: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get versions available for rollback (up to 3 versions older than current)
|
||||
*/
|
||||
export async function getRollbackVersions(): Promise<{ versions: RollbackVersionInfo[] }> {
|
||||
const { data } = await apiClient.get<{ versions: RollbackVersionInfo[] }>(
|
||||
'/admin/system/rollback-versions'
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place update/rollback downloads a full release binary from GitHub, which
|
||||
* can take several minutes on slow links. The global 30s axios timeout would
|
||||
* abort the request mid-download (#4504), so these calls wait as long as the
|
||||
* backend allows (15 minutes server-side).
|
||||
*/
|
||||
const UPDATE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Perform system update
|
||||
* Downloads and applies the latest version
|
||||
*/
|
||||
export async function performUpdate(): Promise<UpdateResult> {
|
||||
const { data } = await apiClient.post<UpdateResult>('/admin/system/update', undefined, {
|
||||
timeout: UPDATE_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to a previous version
|
||||
* @param version - Target version (e.g. "0.1.146"); omit to restore the local backup binary
|
||||
*/
|
||||
export async function rollback(version?: string): Promise<UpdateResult> {
|
||||
const { data } = await apiClient.post<UpdateResult>(
|
||||
'/admin/system/rollback',
|
||||
version ? { version } : undefined,
|
||||
{ timeout: UPDATE_REQUEST_TIMEOUT_MS }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the service
|
||||
*/
|
||||
export async function restartService(): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/system/restart')
|
||||
return data
|
||||
}
|
||||
|
||||
export const systemAPI = {
|
||||
getVersion,
|
||||
checkUpdates,
|
||||
performUpdate,
|
||||
getRollbackVersions,
|
||||
rollback,
|
||||
restartService
|
||||
}
|
||||
|
||||
export default systemAPI
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Admin TLS Fingerprint Profile API endpoints
|
||||
* Handles TLS fingerprint profile CRUD for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/**
|
||||
* TLS fingerprint profile interface
|
||||
*/
|
||||
export interface TLSFingerprintProfile {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
enable_grease: boolean
|
||||
cipher_suites: number[]
|
||||
curves: number[]
|
||||
point_formats: number[]
|
||||
signature_algorithms: number[]
|
||||
alpn_protocols: string[]
|
||||
supported_versions: number[]
|
||||
key_share_groups: number[]
|
||||
psk_modes: number[]
|
||||
extensions: number[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create profile request
|
||||
*/
|
||||
export interface CreateProfileRequest {
|
||||
name: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile request
|
||||
*/
|
||||
export interface UpdateProfileRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
export async function list(): Promise<TLSFingerprintProfile[]> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile[]>('/admin/tls-fingerprint-profiles')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(profileData: CreateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.post<TLSFingerprintProfile>('/admin/tls-fingerprint-profiles', profileData)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, updates: UpdateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.put<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProfile(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const tlsFingerprintProfileAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteProfile
|
||||
}
|
||||
|
||||
export default tlsFingerprintProfileAPI
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Admin Usage API endpoints
|
||||
* Handles admin-level usage logs and statistics retrieval
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse, UsageRequestType } from '@/types'
|
||||
import type { EndpointStat } from '@/types'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface AdminUsageStatsResponse {
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_cache_tokens: number
|
||||
total_cache_creation_tokens: number
|
||||
total_cache_read_tokens: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
total_account_cost: number
|
||||
average_duration_ms: number
|
||||
endpoints?: EndpointStat[]
|
||||
upstream_endpoints?: EndpointStat[]
|
||||
endpoint_paths?: EndpointStat[]
|
||||
}
|
||||
|
||||
export interface SimpleUser {
|
||||
id: number
|
||||
email: string
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
export interface SimpleApiKey {
|
||||
id: number
|
||||
name: string
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface UsageCleanupFilters {
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
request_type?: UsageRequestType | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UsageCleanupTask {
|
||||
id: number
|
||||
status: string
|
||||
filters: UsageCleanupFilters
|
||||
created_by: number
|
||||
deleted_rows: number
|
||||
error_message?: string | null
|
||||
canceled_by?: number | null
|
||||
canceled_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateUsageCleanupTaskRequest {
|
||||
start_date: string
|
||||
end_date: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
request_type?: UsageRequestType | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number
|
||||
exact_total?: boolean
|
||||
billing_mode?: string
|
||||
upstream_model_mismatch?: boolean
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
|
||||
error_phase?: string | null
|
||||
error_category?: string | null
|
||||
status_code?: number | null
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
/**
|
||||
* List all usage logs with optional filters (admin only)
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of usage logs
|
||||
*/
|
||||
export async function list(
|
||||
params: AdminUsageQueryParams,
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<AdminUsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminUsageLog>>('/admin/usage', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage statistics with optional filters (admin only)
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Usage statistics
|
||||
*/
|
||||
export async function getStats(params: {
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
upstream_model_mismatch?: boolean
|
||||
period?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
timezone?: string
|
||||
nocache?: number
|
||||
}): Promise<AdminUsageStatsResponse> {
|
||||
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users by email keyword (admin only)
|
||||
* @param keyword - Email keyword to search
|
||||
* @returns List of matching users (max 30)
|
||||
*/
|
||||
export async function searchUsers(keyword: string): Promise<SimpleUser[]> {
|
||||
const { data } = await apiClient.get<SimpleUser[]>('/admin/usage/search-users', {
|
||||
params: { q: keyword }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Search API keys by user ID and/or keyword (admin only)
|
||||
* @param userId - Optional user ID to filter by
|
||||
* @param keyword - Optional keyword to search in key name
|
||||
* @returns List of matching API keys (max 30)
|
||||
*/
|
||||
export async function searchApiKeys(userId?: number, keyword?: string): Promise<SimpleApiKey[]> {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (userId !== undefined) {
|
||||
params.user_id = userId
|
||||
}
|
||||
if (keyword) {
|
||||
params.q = keyword
|
||||
}
|
||||
const { data } = await apiClient.get<SimpleApiKey[]>('/admin/usage/search-api-keys', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List usage cleanup tasks (admin only)
|
||||
* @param params - Query parameters for pagination
|
||||
* @returns Paginated list of cleanup tasks
|
||||
*/
|
||||
export async function listCleanupTasks(
|
||||
params: { page?: number; page_size?: number },
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<UsageCleanupTask>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageCleanupTask>>('/admin/usage/cleanup-tasks', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a usage cleanup task (admin only)
|
||||
* @param payload - Cleanup task parameters
|
||||
* @returns Created cleanup task
|
||||
*/
|
||||
export async function createCleanupTask(payload: CreateUsageCleanupTaskRequest): Promise<UsageCleanupTask> {
|
||||
const { data } = await apiClient.post<UsageCleanupTask>('/admin/usage/cleanup-tasks', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a usage cleanup task (admin only)
|
||||
* @param taskId - Task ID to cancel
|
||||
*/
|
||||
export async function cancelCleanupTask(taskId: number): Promise<{ id: number; status: string }> {
|
||||
const { data } = await apiClient.post<{ id: number; status: string }>(
|
||||
`/admin/usage/cleanup-tasks/${taskId}/cancel`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const adminUsageAPI = {
|
||||
list,
|
||||
getStats,
|
||||
searchUsers,
|
||||
searchApiKeys,
|
||||
listCleanupTasks,
|
||||
createCleanupTask,
|
||||
cancelCleanupTask
|
||||
}
|
||||
|
||||
export default adminUsageAPI
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Admin User Attributes API endpoints
|
||||
* Handles user custom attribute definitions and values
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
UserAttributeDefinition,
|
||||
UserAttributeValue,
|
||||
CreateUserAttributeRequest,
|
||||
UpdateUserAttributeRequest,
|
||||
UserAttributeValuesMap
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Get all attribute definitions
|
||||
*/
|
||||
export async function listDefinitions(): Promise<UserAttributeDefinition[]> {
|
||||
const { data } = await apiClient.get<UserAttributeDefinition[]>('/admin/user-attributes')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled attribute definitions only
|
||||
*/
|
||||
export async function listEnabledDefinitions(): Promise<UserAttributeDefinition[]> {
|
||||
const { data } = await apiClient.get<UserAttributeDefinition[]>('/admin/user-attributes', {
|
||||
params: { enabled: true }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new attribute definition
|
||||
*/
|
||||
export async function createDefinition(
|
||||
request: CreateUserAttributeRequest
|
||||
): Promise<UserAttributeDefinition> {
|
||||
const { data } = await apiClient.post<UserAttributeDefinition>('/admin/user-attributes', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an attribute definition
|
||||
*/
|
||||
export async function updateDefinition(
|
||||
id: number,
|
||||
request: UpdateUserAttributeRequest
|
||||
): Promise<UserAttributeDefinition> {
|
||||
const { data } = await apiClient.put<UserAttributeDefinition>(
|
||||
`/admin/user-attributes/${id}`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attribute definition
|
||||
*/
|
||||
export async function deleteDefinition(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/user-attributes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder attribute definitions
|
||||
*/
|
||||
export async function reorderDefinitions(ids: number[]): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>('/admin/user-attributes/reorder', {
|
||||
ids
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's attribute values
|
||||
*/
|
||||
export async function getUserAttributeValues(userId: number): Promise<UserAttributeValue[]> {
|
||||
const { data } = await apiClient.get<UserAttributeValue[]>(
|
||||
`/admin/users/${userId}/attributes`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's attribute values (batch)
|
||||
*/
|
||||
export async function updateUserAttributeValues(
|
||||
userId: number,
|
||||
values: UserAttributeValuesMap
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>(
|
||||
`/admin/users/${userId}/attributes`,
|
||||
{ values }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch response type
|
||||
*/
|
||||
export interface BatchUserAttributesResponse {
|
||||
attributes: Record<number, Record<number, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attribute values for multiple users
|
||||
*/
|
||||
export async function getBatchUserAttributes(
|
||||
userIds: number[]
|
||||
): Promise<BatchUserAttributesResponse> {
|
||||
const { data } = await apiClient.post<BatchUserAttributesResponse>(
|
||||
'/admin/user-attributes/batch',
|
||||
{ user_ids: userIds }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const userAttributesAPI = {
|
||||
listDefinitions,
|
||||
listEnabledDefinitions,
|
||||
createDefinition,
|
||||
updateDefinition,
|
||||
deleteDefinition,
|
||||
reorderDefinitions,
|
||||
getUserAttributeValues,
|
||||
updateUserAttributeValues,
|
||||
getBatchUserAttributes
|
||||
}
|
||||
|
||||
export default userAttributesAPI
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Admin Users API endpoints
|
||||
* Handles user management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types'
|
||||
|
||||
export interface AdminBindAuthIdentityChannelRequest {
|
||||
channel: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface AdminBindAuthIdentityRequest {
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
issuer?: string | null
|
||||
metadata?: Record<string, unknown> | null
|
||||
channel?: AdminBindAuthIdentityChannelRequest
|
||||
}
|
||||
|
||||
export interface AdminBoundAuthIdentityChannel {
|
||||
channel: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminBoundAuthIdentity {
|
||||
user_id: number
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
verified_at?: string | null
|
||||
issuer?: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
channel?: AdminBoundAuthIdentityChannel | null
|
||||
}
|
||||
|
||||
export interface BatchUpdateUserLimitsRequest {
|
||||
user_ids: number[]
|
||||
all?: boolean
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
}
|
||||
|
||||
export interface BatchUpdateUserLimitsResponse {
|
||||
affected: number
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters (status, role, search, attributes)
|
||||
* @param options - Optional request options (signal)
|
||||
* @returns Paginated list of users
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: 'active' | 'disabled'
|
||||
role?: 'admin' | 'user'
|
||||
search?: string
|
||||
group_name?: string // fuzzy filter by allowed group name
|
||||
api_key_group_id?: number // filter users by the group their API keys are bound to
|
||||
attributes?: Record<number, string> // attributeId -> value
|
||||
include_subscriptions?: boolean
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<AdminUser>> {
|
||||
// Build params with attribute filters in attr[id]=value format
|
||||
const params: Record<string, any> = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status: filters?.status,
|
||||
role: filters?.role,
|
||||
search: filters?.search,
|
||||
group_name: filters?.group_name,
|
||||
api_key_group_id: filters?.api_key_group_id,
|
||||
include_subscriptions: filters?.include_subscriptions,
|
||||
sort_by: filters?.sort_by,
|
||||
sort_order: filters?.sort_order
|
||||
}
|
||||
|
||||
// Add attribute filters as attr[id]=value
|
||||
if (filters?.attributes) {
|
||||
for (const [attrId, value] of Object.entries(filters.attributes)) {
|
||||
if (value) {
|
||||
params[`attr[${attrId}]`] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminUser>>('/admin/users', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
* @param id - User ID
|
||||
* @param includeDeleted - Whether to include soft-deleted users
|
||||
* @returns User details
|
||||
*/
|
||||
export async function getById(id: number, includeDeleted = false): Promise<AdminUser> {
|
||||
const url = includeDeleted ? `/admin/users/${id}?include_deleted=true` : `/admin/users/${id}`
|
||||
const { data } = await apiClient.get<AdminUser>(url)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user
|
||||
* @param userData - User data (email, password, etc.)
|
||||
* @returns Created user
|
||||
*/
|
||||
export async function create(userData: {
|
||||
email: string
|
||||
password: string
|
||||
username?: string
|
||||
notes?: string
|
||||
role?: 'admin' | 'user'
|
||||
balance?: number
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
allowed_groups?: number[] | null
|
||||
}): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>('/admin/users', userData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user
|
||||
* @param id - User ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateUserRequest): Promise<AdminUser> {
|
||||
const { data } = await apiClient.put<AdminUser>(`/admin/users/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
* @param id - User ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteUser(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/users/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user balance
|
||||
* @param id - User ID
|
||||
* @param balance - New balance
|
||||
* @param operation - Operation type ('set', 'add', 'subtract')
|
||||
* @param notes - Optional notes for the balance adjustment
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function updateBalance(
|
||||
id: number,
|
||||
balance: number,
|
||||
operation: 'set' | 'add' | 'subtract' = 'set',
|
||||
notes?: string
|
||||
): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>(`/admin/users/${id}/balance`, {
|
||||
balance,
|
||||
operation,
|
||||
notes: notes || ''
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user concurrency
|
||||
* @param id - User ID
|
||||
* @param concurrency - New concurrency limit
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function updateConcurrency(id: number, concurrency: number): Promise<AdminUser> {
|
||||
return update(id, { concurrency })
|
||||
}
|
||||
|
||||
/** Overwrite concurrency and/or RPM limits for multiple users in one request. */
|
||||
export async function batchUpdateLimits(
|
||||
request: BatchUpdateUserLimitsRequest
|
||||
): Promise<BatchUpdateUserLimitsResponse> {
|
||||
const { data } = await apiClient.post<BatchUpdateUserLimitsResponse>(
|
||||
'/admin/users/batch-limits',
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user status
|
||||
* @param id - User ID
|
||||
* @param status - New status
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<AdminUser> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's API keys
|
||||
* @param id - User ID
|
||||
* @returns List of user's API keys
|
||||
*/
|
||||
export async function getUserApiKeys(id: number): Promise<PaginatedResponse<ApiKey>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>(`/admin/users/${id}/api-keys`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's usage statistics
|
||||
* @param id - User ID
|
||||
* @param period - Time period
|
||||
* @returns User usage statistics
|
||||
*/
|
||||
export async function getUserUsageStats(
|
||||
id: number,
|
||||
period: string = 'month'
|
||||
): Promise<{
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
total_tokens: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
total_tokens: number
|
||||
}>(`/admin/users/${id}/usage`, {
|
||||
params: { period }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance history item returned from the API
|
||||
*/
|
||||
export interface BalanceHistoryItem {
|
||||
id: number
|
||||
code: string
|
||||
type: string
|
||||
value: number
|
||||
status: string
|
||||
used_by: number | null
|
||||
used_at: string | null
|
||||
created_at: string
|
||||
group_id: number | null
|
||||
validity_days: number
|
||||
notes: string
|
||||
user?: { id: number; email: string } | null
|
||||
group?: { id: number; name: string } | null
|
||||
}
|
||||
|
||||
// Balance history response extends pagination with total_recharged summary
|
||||
export interface BalanceHistoryResponse extends PaginatedResponse<BalanceHistoryItem> {
|
||||
total_recharged: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's balance/concurrency change history
|
||||
* @param id - User ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @param type - Optional type filter (balance, affiliate_balance, admin_balance, concurrency, admin_concurrency, subscription)
|
||||
* @returns Paginated balance history with total_recharged
|
||||
*/
|
||||
export async function getUserBalanceHistory(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
type?: string
|
||||
): Promise<BalanceHistoryResponse> {
|
||||
const params: Record<string, any> = { page, page_size: pageSize }
|
||||
if (type) params.type = type
|
||||
const { data } = await apiClient.get<BalanceHistoryResponse>(
|
||||
`/admin/users/${id}/balance-history`,
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace user's exclusive group
|
||||
* @param userId - User ID
|
||||
* @param oldGroupId - Current group ID to replace
|
||||
* @param newGroupId - New group ID to replace with
|
||||
* @returns Number of migrated keys
|
||||
*/
|
||||
export async function replaceGroup(
|
||||
userId: number,
|
||||
oldGroupId: number,
|
||||
newGroupId: number
|
||||
): Promise<{ migrated_keys: number }> {
|
||||
const { data } = await apiClient.post<{ migrated_keys: number }>(
|
||||
`/admin/users/${userId}/replace-group`,
|
||||
{ old_group_id: oldGroupId, new_group_id: newGroupId }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function bindUserAuthIdentity(
|
||||
userId: number,
|
||||
input: AdminBindAuthIdentityRequest
|
||||
): Promise<AdminBoundAuthIdentity> {
|
||||
const { data } = await apiClient.post<AdminBoundAuthIdentity>(
|
||||
`/admin/users/${userId}/auth-identities`,
|
||||
input
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform quota types
|
||||
*/
|
||||
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
|
||||
export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
export interface PlatformQuotaItem {
|
||||
platform: PlatformQuotaPlatform
|
||||
daily_limit_usd: number | null
|
||||
weekly_limit_usd: number | null
|
||||
monthly_limit_usd: number | null
|
||||
daily_usage_usd: number
|
||||
weekly_usage_usd: number
|
||||
monthly_usage_usd: number
|
||||
daily_window_start?: string | null
|
||||
weekly_window_start?: string | null
|
||||
monthly_window_start?: string | null
|
||||
daily_window_resets_at?: string | null
|
||||
weekly_window_resets_at?: string | null
|
||||
monthly_window_resets_at?: string | null
|
||||
}
|
||||
|
||||
export interface PlatformQuotaUpdateItem {
|
||||
platform: PlatformQuotaPlatform
|
||||
daily_limit_usd: number | null
|
||||
weekly_limit_usd: number | null
|
||||
monthly_limit_usd: number | null
|
||||
}
|
||||
|
||||
export interface PlatformQuotasResponse {
|
||||
platform_quotas: PlatformQuotaItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's platform quotas
|
||||
*/
|
||||
export async function getPlatformQuotas(id: number): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.get<PlatformQuotasResponse>(
|
||||
`/admin/users/${id}/platform-quotas`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace user's platform quotas (全量替换)
|
||||
*/
|
||||
export async function updatePlatformQuotas(
|
||||
id: number,
|
||||
quotas: PlatformQuotaUpdateItem[]
|
||||
): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.put<PlatformQuotasResponse>(
|
||||
`/admin/users/${id}/platform-quotas`,
|
||||
{ quotas }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a single (platform, window) usage immediately
|
||||
*/
|
||||
export async function resetPlatformQuotaWindow(
|
||||
id: number,
|
||||
platform: PlatformQuotaPlatform,
|
||||
window: PlatformQuotaWindow
|
||||
): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.post<PlatformQuotasResponse>(
|
||||
`/admin/users/${id}/platform-quotas/reset`,
|
||||
{ platform, window }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteUser,
|
||||
updateBalance,
|
||||
updateConcurrency,
|
||||
batchUpdateLimits,
|
||||
toggleStatus,
|
||||
getUserApiKeys,
|
||||
getUserUsageStats,
|
||||
getUserBalanceHistory,
|
||||
replaceGroup,
|
||||
bindUserAuthIdentity,
|
||||
getPlatformQuotas,
|
||||
updatePlatformQuotas,
|
||||
resetPlatformQuotaWindow,
|
||||
}
|
||||
|
||||
export default usersAPI
|
||||
@@ -0,0 +1,78 @@
|
||||
export const ADMIN_UI_REQUEST_HEADER = 'X-Admin-UI-Request'
|
||||
export const USER_UI_REQUEST_HEADER = 'X-User-UI-Request'
|
||||
|
||||
function isAdminPath(path: string): boolean {
|
||||
return (
|
||||
path === '/admin' ||
|
||||
path.startsWith('/admin/') ||
|
||||
path === '/api/v1/admin' ||
|
||||
path.startsWith('/api/v1/admin/')
|
||||
)
|
||||
}
|
||||
|
||||
function requestPath(rawURL: string): string {
|
||||
const value = rawURL.trim()
|
||||
if (!value) return ''
|
||||
try {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
|
||||
return new URL(value, origin).pathname
|
||||
} catch {
|
||||
return value.split(/[?#]/, 1)[0]
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize Axios relative paths and absolute API paths to a comparable form. */
|
||||
function normalizeAPIPath(path: string): string {
|
||||
const raw = requestPath(path)
|
||||
if (!raw) return ''
|
||||
if (raw === '/api/v1' || raw.startsWith('/api/v1/')) {
|
||||
return raw.slice('/api/v1'.length) || '/'
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return raw
|
||||
}
|
||||
return `/${raw}`
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing web APIs that may emit Server-Timing when ENABLE_SERVER_TIMING is on.
|
||||
* Mirrors backend isUserTimingPath allowlist (excluding public payment surfaces).
|
||||
*/
|
||||
export function isUserTimingAPIPath(requestURL: string): boolean {
|
||||
const path = normalizeAPIPath(requestURL)
|
||||
if (!path) return false
|
||||
|
||||
if (
|
||||
path === '/auth/me' ||
|
||||
path === '/auth/revoke-all-sessions' ||
|
||||
path === '/auth/oauth/bind-token'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (path === '/user' || path.startsWith('/user/')) return true
|
||||
if (path === '/keys' || path.startsWith('/keys/')) return true
|
||||
if (path === '/groups/available' || path === '/groups/rates') return true
|
||||
if (path === '/channels/available') return true
|
||||
if (path === '/usage' || path.startsWith('/usage/')) return true
|
||||
if (path === '/announcements' || path.startsWith('/announcements/')) return true
|
||||
if (path === '/redeem' || path.startsWith('/redeem/')) return true
|
||||
if (path === '/subscriptions' || path.startsWith('/subscriptions/')) return true
|
||||
if (path === '/channel-monitors' || path.startsWith('/channel-monitors/')) return true
|
||||
if (path.startsWith('/payment/')) {
|
||||
if (path.startsWith('/payment/public') || path.startsWith('/payment/webhook')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function shouldMarkAdminUIRequest(requestURL: string, pagePath?: string): boolean {
|
||||
const currentPath =
|
||||
pagePath ?? (typeof window !== 'undefined' ? window.location.pathname : '')
|
||||
return isAdminPath(requestPath(requestURL)) || isAdminPath(currentPath)
|
||||
}
|
||||
|
||||
export function shouldMarkUserUIRequest(requestURL: string): boolean {
|
||||
return isUserTimingAPIPath(requestURL)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* User Announcements API endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
|
||||
export async function list(unreadOnly: boolean = false): Promise<UserAnnouncement[]> {
|
||||
const { data } = await apiClient.get<UserAnnouncement[]>('/announcements', {
|
||||
params: unreadOnly ? { unread_only: 1 } : {}
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function markRead(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>(`/announcements/${id}/read`)
|
||||
return data
|
||||
}
|
||||
|
||||
const announcementsAPI = {
|
||||
list,
|
||||
markRead
|
||||
}
|
||||
|
||||
export default announcementsAPI
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* Authentication API endpoints
|
||||
* Handles user login, registration, and logout operations
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import { refreshAuthTokens, type RefreshTokenResponse } from './tokenRefresh'
|
||||
export type { RefreshTokenResponse } from './tokenRefresh'
|
||||
import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
AuthResponse,
|
||||
CurrentUserResponse,
|
||||
SendVerifyCodeRequest,
|
||||
SendVerifyCodeResponse,
|
||||
PublicSettings,
|
||||
ActionCaptchaRequestProof,
|
||||
TotpLoginResponse,
|
||||
TotpLogin2FARequest
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Login response type - can be either full auth or 2FA required
|
||||
*/
|
||||
export type LoginResponse = AuthResponse | TotpLoginResponse
|
||||
|
||||
export type OAuthLoginProvider =
|
||||
| 'github'
|
||||
| 'google'
|
||||
| 'linuxdo'
|
||||
| 'dingtalk'
|
||||
| 'wechat'
|
||||
| 'oidc'
|
||||
|
||||
export interface OAuthLoginStart {
|
||||
provider: OAuthLoginProvider
|
||||
params: Record<string, string>
|
||||
}
|
||||
|
||||
export interface OAuthLoginStartResponse {
|
||||
authorize_url: string
|
||||
}
|
||||
|
||||
export function buildOAuthLoginStartURL(request: OAuthLoginStart): string {
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const query = new URLSearchParams(request.params).toString()
|
||||
const path = `${normalized}/auth/oauth/${request.provider}/start`
|
||||
return query ? `${path}?${query}` : path
|
||||
}
|
||||
|
||||
export async function startOAuthLogin(
|
||||
request: OAuthLoginStart,
|
||||
proof: ActionCaptchaRequestProof
|
||||
): Promise<OAuthLoginStartResponse> {
|
||||
const { data } = await apiClient.post<OAuthLoginStartResponse>(
|
||||
`/auth/oauth/${request.provider}/start`,
|
||||
proof,
|
||||
{ params: request.params }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if login response requires 2FA
|
||||
*/
|
||||
export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse {
|
||||
return 'requires_2fa' in response && response.requires_2fa === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Store authentication token in localStorage
|
||||
*/
|
||||
export function setAuthToken(token: string): void {
|
||||
localStorage.setItem('auth_token', token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store refresh token in localStorage
|
||||
*/
|
||||
export function setRefreshToken(token: string): void {
|
||||
localStorage.setItem('refresh_token', token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store token expiration timestamp in localStorage
|
||||
* Converts expires_in (seconds) to absolute timestamp (milliseconds)
|
||||
*/
|
||||
export function setTokenExpiresAt(expiresIn: number): void {
|
||||
const expiresAt = Date.now() + expiresIn * 1000
|
||||
localStorage.setItem('token_expires_at', String(expiresAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication token from localStorage
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token from localStorage
|
||||
*/
|
||||
export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem('refresh_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token expiration timestamp from localStorage
|
||||
*/
|
||||
export function getTokenExpiresAt(): number | null {
|
||||
const value = localStorage.getItem('token_expires_at')
|
||||
return value ? parseInt(value, 10) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication token from localStorage
|
||||
*/
|
||||
export function clearAuthToken(): void {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
localStorage.removeItem('token_expires_at')
|
||||
}
|
||||
|
||||
/**
|
||||
* User login
|
||||
* @param credentials - Email and password
|
||||
* @returns Authentication response with token and user data, or 2FA required response
|
||||
*/
|
||||
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials)
|
||||
|
||||
// Only store token if 2FA is not required
|
||||
if (!isTotp2FARequired(data)) {
|
||||
setAuthToken(data.access_token)
|
||||
if (data.refresh_token) {
|
||||
setRefreshToken(data.refresh_token)
|
||||
}
|
||||
if (data.expires_in) {
|
||||
setTokenExpiresAt(data.expires_in)
|
||||
}
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login with 2FA code
|
||||
* @param request - Temp token and TOTP code
|
||||
* @returns Authentication response with token and user data
|
||||
*/
|
||||
export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request)
|
||||
|
||||
// Store token and user data
|
||||
setAuthToken(data.access_token)
|
||||
if (data.refresh_token) {
|
||||
setRefreshToken(data.refresh_token)
|
||||
}
|
||||
if (data.expires_in) {
|
||||
setTokenExpiresAt(data.expires_in)
|
||||
}
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
* @param userData - Registration data (username, email, password)
|
||||
* @returns Authentication response with token and user data
|
||||
*/
|
||||
export async function register(userData: RegisterRequest): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/register', userData)
|
||||
|
||||
// Store token and user data
|
||||
setAuthToken(data.access_token)
|
||||
if (data.refresh_token) {
|
||||
setRefreshToken(data.refresh_token)
|
||||
}
|
||||
if (data.expires_in) {
|
||||
setTokenExpiresAt(data.expires_in)
|
||||
}
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
* @returns User profile data
|
||||
*/
|
||||
export async function getCurrentUser() {
|
||||
return apiClient.get<CurrentUserResponse>('/auth/me')
|
||||
}
|
||||
|
||||
/**
|
||||
* User logout
|
||||
* Clears authentication token and user data from localStorage
|
||||
* Optionally revokes the refresh token on the server
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
const refreshToken = getRefreshToken()
|
||||
|
||||
// Try to revoke the refresh token on the server
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await apiClient.post('/auth/logout', { refresh_token: refreshToken })
|
||||
} catch {
|
||||
// Ignore errors - we still want to clear local state
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token response
|
||||
*/
|
||||
export interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
||||
auth_result?: string
|
||||
redirect?: string
|
||||
error?: string
|
||||
requires_2fa?: boolean
|
||||
temp_token?: string
|
||||
user_email_masked?: string
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}
|
||||
|
||||
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
|
||||
|
||||
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {
|
||||
auth_result?: string
|
||||
}
|
||||
|
||||
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
|
||||
auth_result?: string
|
||||
provider?: string
|
||||
redirect?: string
|
||||
}
|
||||
|
||||
export type OAuthCompletionKind = 'login' | 'bind'
|
||||
|
||||
export interface OAuthAdoptionDecision {
|
||||
adoptDisplayName?: boolean
|
||||
adoptAvatar?: boolean
|
||||
}
|
||||
|
||||
function serializeOAuthAdoptionDecision(
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Record<string, boolean> {
|
||||
const payload: Record<string, boolean> = {}
|
||||
|
||||
if (typeof decision?.adoptDisplayName === 'boolean') {
|
||||
payload.adopt_display_name = decision.adoptDisplayName
|
||||
}
|
||||
if (typeof decision?.adoptAvatar === 'boolean') {
|
||||
payload.adopt_avatar = decision.adoptAvatar
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export function isOAuthLoginCompletion(
|
||||
completion: Partial<OAuthTokenResponse>
|
||||
): completion is OAuthTokenResponse {
|
||||
return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0
|
||||
}
|
||||
|
||||
export function getOAuthCompletionKind(
|
||||
completion: Partial<OAuthTokenResponse>
|
||||
): OAuthCompletionKind {
|
||||
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
|
||||
}
|
||||
|
||||
export function getPendingOAuthBindLoginKind(
|
||||
completion: PendingOAuthBindLoginResponse
|
||||
): OAuthCompletionKind {
|
||||
return getOAuthCompletionKind(completion)
|
||||
}
|
||||
|
||||
export function isPendingOAuthCreateAccountRequired(
|
||||
completion: Pick<PendingOAuthBindLoginResponse, 'error'>
|
||||
): boolean {
|
||||
return completion.error === 'invitation_required'
|
||||
}
|
||||
|
||||
export function hasPendingOAuthSuggestedProfile(
|
||||
completion: Pick<
|
||||
PendingOAuthBindLoginResponse,
|
||||
'suggested_display_name' | 'suggested_avatar_url'
|
||||
>
|
||||
): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
|
||||
if (tokens.refresh_token) {
|
||||
setRefreshToken(tokens.refresh_token)
|
||||
}
|
||||
if (tokens.expires_in) {
|
||||
setTokenExpiresAt(tokens.expires_in)
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
|
||||
if (!getAuthToken()) {
|
||||
return
|
||||
}
|
||||
await apiClient.post('/auth/oauth/bind-token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token
|
||||
* @returns New token pair
|
||||
*/
|
||||
export async function refreshToken(): Promise<RefreshTokenResponse> {
|
||||
return refreshAuthTokens()
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all sessions for the current user
|
||||
* @returns Response with message
|
||||
*/
|
||||
export async function revokeAllSessions(): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>('/auth/revoke-all-sessions')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* @returns True if user has valid token
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return getAuthToken() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public settings (no auth required)
|
||||
* @returns Public settings including registration and Turnstile config
|
||||
*/
|
||||
export async function getPublicSettings(): Promise<PublicSettings> {
|
||||
const { data } = await apiClient.get<PublicSettings>('/settings/public')
|
||||
return data
|
||||
}
|
||||
|
||||
export type WeChatOAuthMode = 'open' | 'mp'
|
||||
export type WeChatOAuthUnavailableReason =
|
||||
| 'not_configured'
|
||||
| 'capability_unknown'
|
||||
| 'external_browser_required'
|
||||
| 'wechat_browser_required'
|
||||
| 'native_app_required'
|
||||
|
||||
export interface ResolvedWeChatOAuthStart {
|
||||
mode: WeChatOAuthMode | null
|
||||
openEnabled: boolean
|
||||
mpEnabled: boolean
|
||||
mobileEnabled: boolean
|
||||
isWeChatBrowser: boolean
|
||||
unavailableReason: WeChatOAuthUnavailableReason | null
|
||||
}
|
||||
|
||||
export type WeChatOAuthPublicSettings = {
|
||||
wechat_oauth_enabled?: boolean
|
||||
wechat_oauth_open_enabled?: boolean
|
||||
wechat_oauth_mp_enabled?: boolean
|
||||
wechat_oauth_mobile_enabled?: boolean
|
||||
}
|
||||
|
||||
export function isWeChatWebOAuthEnabled(
|
||||
settings: WeChatOAuthPublicSettings | null | undefined,
|
||||
): boolean {
|
||||
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
|
||||
const hasExplicitCapabilities =
|
||||
typeof settings?.wechat_oauth_open_enabled === 'boolean' ||
|
||||
typeof settings?.wechat_oauth_mp_enabled === 'boolean'
|
||||
|
||||
if (!hasExplicitCapabilities) {
|
||||
return legacyEnabled
|
||||
}
|
||||
|
||||
return settings?.wechat_oauth_open_enabled === true || settings?.wechat_oauth_mp_enabled === true
|
||||
}
|
||||
|
||||
export function hasExplicitWeChatOAuthCapabilities(
|
||||
settings: WeChatOAuthPublicSettings | null | undefined,
|
||||
): settings is WeChatOAuthPublicSettings & {
|
||||
wechat_oauth_open_enabled: boolean
|
||||
wechat_oauth_mp_enabled: boolean
|
||||
} {
|
||||
return typeof settings?.wechat_oauth_open_enabled === 'boolean'
|
||||
&& typeof settings?.wechat_oauth_mp_enabled === 'boolean'
|
||||
}
|
||||
|
||||
export function resolveWeChatOAuthStart(
|
||||
settings: WeChatOAuthPublicSettings | null | undefined,
|
||||
userAgent?: string
|
||||
): ResolvedWeChatOAuthStart {
|
||||
const normalizedUserAgent = (userAgent
|
||||
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
|
||||
?? '').trim()
|
||||
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
|
||||
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
|
||||
const openEnabled = typeof settings?.wechat_oauth_open_enabled === 'boolean'
|
||||
? settings.wechat_oauth_open_enabled
|
||||
: legacyEnabled
|
||||
const mpEnabled = typeof settings?.wechat_oauth_mp_enabled === 'boolean'
|
||||
? settings.wechat_oauth_mp_enabled
|
||||
: legacyEnabled
|
||||
const mobileEnabled = typeof settings?.wechat_oauth_mobile_enabled === 'boolean'
|
||||
? settings.wechat_oauth_mobile_enabled
|
||||
: false
|
||||
|
||||
if (isWeChatBrowser) {
|
||||
if (mpEnabled) {
|
||||
return { mode: 'mp', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
|
||||
}
|
||||
if (openEnabled) {
|
||||
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'external_browser_required' }
|
||||
}
|
||||
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
|
||||
}
|
||||
|
||||
if (openEnabled) {
|
||||
return { mode: 'open', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
|
||||
}
|
||||
if (mpEnabled) {
|
||||
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'wechat_browser_required' }
|
||||
}
|
||||
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
|
||||
}
|
||||
|
||||
export function resolveWeChatOAuthStartStrict(
|
||||
settings: WeChatOAuthPublicSettings | null | undefined,
|
||||
userAgent?: string,
|
||||
): ResolvedWeChatOAuthStart {
|
||||
const normalizedUserAgent = (userAgent
|
||||
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
|
||||
?? '').trim()
|
||||
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
|
||||
|
||||
if (!hasExplicitWeChatOAuthCapabilities(settings)) {
|
||||
return {
|
||||
mode: null,
|
||||
openEnabled: false,
|
||||
mpEnabled: false,
|
||||
mobileEnabled: false,
|
||||
isWeChatBrowser,
|
||||
unavailableReason: 'capability_unknown',
|
||||
}
|
||||
}
|
||||
|
||||
return resolveWeChatOAuthStart(settings, normalizedUserAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification code to email
|
||||
* @param request - Email and optional Turnstile token
|
||||
* @returns Response with countdown seconds
|
||||
*/
|
||||
export async function sendVerifyCode(
|
||||
request: SendVerifyCodeRequest
|
||||
): Promise<SendVerifyCodeResponse> {
|
||||
const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function sendPendingOAuthVerifyCode(
|
||||
request: SendVerifyCodeRequest
|
||||
): Promise<PendingOAuthSendVerifyCodeResponse> {
|
||||
const { data } = await apiClient.post<PendingOAuthSendVerifyCodeResponse>(
|
||||
'/auth/oauth/pending/send-verify-code',
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate promo code response
|
||||
*/
|
||||
export interface ValidatePromoCodeResponse {
|
||||
valid: boolean
|
||||
bonus_amount?: number
|
||||
error_code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate promo code (public endpoint, no auth required)
|
||||
* @param code - Promo code to validate
|
||||
* @returns Validation result with bonus amount if valid
|
||||
*/
|
||||
export async function validatePromoCode(code: string): Promise<ValidatePromoCodeResponse> {
|
||||
const { data } = await apiClient.post<ValidatePromoCodeResponse>('/auth/validate-promo-code', { code })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate invitation code response
|
||||
*/
|
||||
export interface ValidateInvitationCodeResponse {
|
||||
valid: boolean
|
||||
error_code?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate invitation code (public endpoint, no auth required)
|
||||
* @param code - Invitation code to validate
|
||||
* @returns Validation result
|
||||
*/
|
||||
export async function validateInvitationCode(code: string): Promise<ValidateInvitationCodeResponse> {
|
||||
const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password request
|
||||
*/
|
||||
export interface ForgotPasswordRequest {
|
||||
email: string
|
||||
turnstile_token?: string
|
||||
tencent_captcha_ticket?: string
|
||||
tencent_captcha_randstr?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password response
|
||||
*/
|
||||
export interface ForgotPasswordResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset link
|
||||
* @param request - Email and optional Turnstile token
|
||||
* @returns Response with message
|
||||
*/
|
||||
export async function forgotPassword(request: ForgotPasswordRequest): Promise<ForgotPasswordResponse> {
|
||||
const { data } = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password request
|
||||
*/
|
||||
export interface ResetPasswordRequest {
|
||||
email: string
|
||||
token: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password response
|
||||
*/
|
||||
export interface ResetPasswordResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
* @param request - Email, token, and new password
|
||||
* @returns Response with message
|
||||
*/
|
||||
export async function resetPassword(request: ResetPasswordRequest): Promise<ResetPasswordResponse> {
|
||||
const { data } = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete LinuxDo OAuth registration by supplying an invitation code
|
||||
* @param invitationCode - Invitation code entered by the user
|
||||
* @returns Token pair on success
|
||||
*/
|
||||
export async function completeLinuxDoOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<OAuthTokenResponse> {
|
||||
return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OIDC OAuth registration by supplying an invitation code
|
||||
* @param invitationCode - Invitation code entered by the user
|
||||
* @returns Token pair on success
|
||||
*/
|
||||
export async function completeOIDCOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<OAuthTokenResponse> {
|
||||
return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function completeWeChatOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<OAuthTokenResponse> {
|
||||
return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
async function createPendingOAuthAccount(
|
||||
provider: 'linuxdo' | 'oidc' | 'wechat' | 'dingtalk',
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
const normalizedAffiliateCode = affiliateCode?.trim()
|
||||
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
|
||||
`/auth/oauth/${provider}/complete-registration`,
|
||||
{
|
||||
invitation_code: invitationCode,
|
||||
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createPendingLinuxDoOAuthAccount(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function createPendingOIDCOAuthAccount(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function createPendingWeChatOAuthAccount(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function createPendingDingTalkOAuthAccount(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function completePendingOAuthBindLogin(
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<PendingOAuthBindLoginResponse> {
|
||||
const { data } = await apiClient.post<PendingOAuthBindLoginResponse>(
|
||||
'/auth/oauth/pending/exchange',
|
||||
serializeOAuthAdoptionDecision(decision)
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangePendingOAuthCompletion(
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<PendingOAuthExchangeResponse> {
|
||||
return completePendingOAuthBindLogin(decision)
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
login,
|
||||
login2FA,
|
||||
isTotp2FARequired,
|
||||
register,
|
||||
getCurrentUser,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
setAuthToken,
|
||||
setRefreshToken,
|
||||
setTokenExpiresAt,
|
||||
getAuthToken,
|
||||
getRefreshToken,
|
||||
getTokenExpiresAt,
|
||||
clearAuthToken,
|
||||
getPublicSettings,
|
||||
sendVerifyCode,
|
||||
sendPendingOAuthVerifyCode,
|
||||
validatePromoCode,
|
||||
validateInvitationCode,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
refreshToken,
|
||||
revokeAllSessions,
|
||||
getPendingOAuthBindLoginKind,
|
||||
isPendingOAuthCreateAccountRequired,
|
||||
hasPendingOAuthSuggestedProfile,
|
||||
completePendingOAuthBindLogin,
|
||||
createPendingLinuxDoOAuthAccount,
|
||||
createPendingOIDCOAuthAccount,
|
||||
createPendingWeChatOAuthAccount,
|
||||
exchangePendingOAuthCompletion,
|
||||
completeLinuxDoOAuthRegistration,
|
||||
completeOIDCOAuthRegistration,
|
||||
completeWeChatOAuthRegistration,
|
||||
createPendingDingTalkOAuthAccount
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
@@ -0,0 +1,245 @@
|
||||
import { buildGatewayUrl } from './client'
|
||||
|
||||
export type BatchImageStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'indexing'
|
||||
| 'processing_results'
|
||||
| 'settling'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'output_deleted'
|
||||
| string
|
||||
|
||||
export interface BatchImageSubmitItem {
|
||||
custom_id: string
|
||||
prompt: string
|
||||
output_count?: number
|
||||
reference_images?: BatchImageReferenceImage[]
|
||||
}
|
||||
|
||||
export interface BatchImageReferenceImage {
|
||||
id?: string
|
||||
type?: string
|
||||
mime_type: string
|
||||
data?: string
|
||||
file_uri?: string
|
||||
}
|
||||
|
||||
export interface BatchImageSubmitRequest {
|
||||
model: string
|
||||
task_name?: string
|
||||
parent_batch_id?: string
|
||||
provider?: '' | 'gemini_api' | 'vertex' | string
|
||||
image_size?: '1K' | '2K' | '4K' | string
|
||||
response_mime_type?: string
|
||||
aspect_ratio?: string
|
||||
items: BatchImageSubmitItem[]
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BatchImageJob {
|
||||
id: string
|
||||
object: string
|
||||
task_name: string
|
||||
parent_batch_id?: string | null
|
||||
status: BatchImageStatus
|
||||
model: string
|
||||
provider: string
|
||||
item_count: number
|
||||
success_count: number
|
||||
fail_count: number
|
||||
estimated_cost: number
|
||||
hold_amount: number
|
||||
actual_cost: number | null
|
||||
created_at: number
|
||||
submitted_at: number | null
|
||||
settled_at: number | null
|
||||
downloaded_at?: number | null
|
||||
output_deleted_at?: number | null
|
||||
}
|
||||
|
||||
export interface BatchImageItem {
|
||||
batch_id?: string
|
||||
source_task_name?: string
|
||||
custom_id: string
|
||||
status: string
|
||||
prompt_preview?: string | null
|
||||
mime_type: string | null
|
||||
file_extension: string | null
|
||||
image_count: number
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
source?: 'provider' | 'system' | string
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface BatchImageItemsResponse {
|
||||
object: string
|
||||
data: BatchImageItem[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export interface BatchImageJobsResponse {
|
||||
object: string
|
||||
data: BatchImageJob[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export interface BatchImageModel {
|
||||
id: string
|
||||
object: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface BatchImageModelsResponse {
|
||||
object: string
|
||||
data: BatchImageModel[]
|
||||
}
|
||||
|
||||
export interface BatchImageJobsListOptions {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
status?: string
|
||||
taskName?: string
|
||||
downloaded?: '' | 'true' | 'false' | string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
async function parseBatchImageError(response: Response): Promise<Error> {
|
||||
try {
|
||||
const body = await response.json()
|
||||
const message = body?.error?.message || body?.message || response.statusText
|
||||
const error = new Error(message)
|
||||
;(error as any).code = body?.error?.code || response.status
|
||||
;(error as any).status = response.status
|
||||
;(error as any).requestId = response.headers.get('X-Request-Id') || ''
|
||||
return error
|
||||
} catch {
|
||||
const error = new Error(response.statusText || `HTTP ${response.status}`)
|
||||
;(error as any).code = response.status
|
||||
;(error as any).status = response.status
|
||||
;(error as any).requestId = response.headers.get('X-Request-Id') || ''
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(apiKey: string, extra?: HeadersInit): HeadersInit {
|
||||
return {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitBatchImageJob(
|
||||
apiKey: string,
|
||||
payload: BatchImageSubmitRequest,
|
||||
idempotencyKey: string,
|
||||
): Promise<BatchImageJob> {
|
||||
const response = await fetch(buildGatewayUrl('/v1/images/batches'), {
|
||||
method: 'POST',
|
||||
headers: authHeaders(apiKey, {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': idempotencyKey,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function getBatchImageJob(apiKey: string, batchId: string): Promise<BatchImageJob> {
|
||||
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}`), {
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function listBatchImageJobs(apiKey: string, options: number | BatchImageJobsListOptions = 20): Promise<BatchImageJobsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (typeof options === 'number') {
|
||||
params.set('limit', String(options))
|
||||
} else {
|
||||
params.set('limit', String(options.limit || 20))
|
||||
if (options.cursor) params.set('cursor', options.cursor)
|
||||
if (options.status) params.set('status', options.status)
|
||||
if (options.taskName) params.set('task_name', options.taskName)
|
||||
if (options.downloaded) params.set('downloaded', options.downloaded)
|
||||
if (options.from) params.set('from', options.from)
|
||||
if (options.to) params.set('to', options.to)
|
||||
}
|
||||
const response = await fetch(buildGatewayUrl(`/v1/images/batches?${params.toString()}`), {
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function listBatchImageModels(apiKey: string): Promise<BatchImageModelsResponse> {
|
||||
const response = await fetch(buildGatewayUrl('/v1/images/batches/models'), {
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function listBatchImageItems(
|
||||
apiKey: string,
|
||||
batchId: string,
|
||||
status = '',
|
||||
): Promise<BatchImageItemsResponse> {
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : ''
|
||||
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/items${query}`), {
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function cancelBatchImageJob(apiKey: string, batchId: string): Promise<BatchImageJob> {
|
||||
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/cancel`), {
|
||||
method: 'POST',
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function downloadBatchImageZip(apiKey: string, batchId: string): Promise<Blob> {
|
||||
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/download`), {
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export async function getBatchImageItemContent(apiKey: string, batchId: string, customId: string, imageIndex = 0): Promise<Blob> {
|
||||
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}/items/${encodeURIComponent(customId)}/content?image_index=${encodeURIComponent(String(imageIndex))}`), {
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export async function deleteBatchImageJobRecord(apiKey: string, batchId: string): Promise<void> {
|
||||
const response = await fetch(buildGatewayUrl(`/v1/images/batches/${encodeURIComponent(batchId)}`), {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(apiKey),
|
||||
})
|
||||
if (!response.ok) throw await parseBatchImageError(response)
|
||||
}
|
||||
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* User-facing Channel Monitor API endpoints
|
||||
* Read-only views for end users to inspect channel availability/status.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { MonitorQuotaSnapshot, Provider, MonitorStatus } from './admin/channelMonitor'
|
||||
|
||||
export type { Provider, MonitorStatus } from './admin/channelMonitor'
|
||||
|
||||
export interface UserMonitorExtraModel {
|
||||
model: string
|
||||
status: MonitorStatus
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
export interface MonitorTimelinePoint {
|
||||
status: MonitorStatus
|
||||
latency_ms: number | null
|
||||
ping_latency_ms: number | null
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface UserMonitorView {
|
||||
id: number
|
||||
name: string
|
||||
provider: Provider
|
||||
group_name: string
|
||||
primary_model: string
|
||||
primary_status: MonitorStatus
|
||||
primary_latency_ms: number | null
|
||||
primary_ping_latency_ms: number | null
|
||||
availability_7d: number
|
||||
extra_models: UserMonitorExtraModel[]
|
||||
timeline: MonitorTimelinePoint[]
|
||||
/**
|
||||
* 主模型最近配额快照。仅当系统开启 channel_monitor_show_quota 时
|
||||
* 服务端才会下发(关闭时服务端已剥离,前端 flag 仅作纵深防御)。
|
||||
*/
|
||||
latest_quota?: MonitorQuotaSnapshot | null
|
||||
}
|
||||
|
||||
export interface UserMonitorListResponse {
|
||||
items: UserMonitorView[]
|
||||
}
|
||||
|
||||
export interface UserMonitorModelDetail {
|
||||
model: string
|
||||
latest_status: MonitorStatus
|
||||
latest_latency_ms: number | null
|
||||
availability_7d: number
|
||||
availability_15d: number
|
||||
availability_30d: number
|
||||
avg_latency_7d_ms: number | null
|
||||
}
|
||||
|
||||
export interface UserMonitorDetail {
|
||||
id: number
|
||||
name: string
|
||||
provider: Provider
|
||||
group_name: string
|
||||
models: UserMonitorModelDetail[]
|
||||
}
|
||||
|
||||
/**
|
||||
* List all monitor views available to the current user.
|
||||
*/
|
||||
export async function list(options?: { signal?: AbortSignal }): Promise<UserMonitorListResponse> {
|
||||
const { data } = await apiClient.get<UserMonitorListResponse>('/channel-monitors', {
|
||||
signal: options?.signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed status (multi-window availability + latency) for a single monitor.
|
||||
*/
|
||||
export async function status(id: number): Promise<UserMonitorDetail> {
|
||||
const { data } = await apiClient.get<UserMonitorDetail>(`/channel-monitors/${id}/status`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const channelMonitorUserAPI = {
|
||||
list,
|
||||
status,
|
||||
}
|
||||
|
||||
export default channelMonitorUserAPI
|
||||
@@ -0,0 +1,271 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export type MonitorRange = '90m' | '24h' | '7d' | '30d'
|
||||
export type HealthState = 'unknown' | 'healthy' | 'warning' | 'critical'
|
||||
/** Fine-grained score band for multi-stop green→yellow→red gradients (score0..score10). */
|
||||
export type HealthScoreBand =
|
||||
| 'unknown'
|
||||
| 'score0'
|
||||
| 'score1'
|
||||
| 'score2'
|
||||
| 'score3'
|
||||
| 'score4'
|
||||
| 'score5'
|
||||
| 'score6'
|
||||
| 'score7'
|
||||
| 'score8'
|
||||
| 'score9'
|
||||
| 'score10'
|
||||
export type MonitorMatrixGroupBy = 'platform' | 'platform_group' | 'platform_model' | 'platform_group_model'
|
||||
|
||||
export interface MonitorFilter {
|
||||
range: MonitorRange
|
||||
platforms: string[]
|
||||
groupIds: number[]
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface LatencyMetric {
|
||||
sample_count: number
|
||||
p50_ms: number | null
|
||||
p90_ms?: number | null
|
||||
p95_ms: number | null
|
||||
avg_ms: number | null
|
||||
}
|
||||
|
||||
export interface MonitorMetric {
|
||||
success_requests: number
|
||||
error_requests: number
|
||||
request_count: number
|
||||
token_count: number
|
||||
rpm: number
|
||||
tpm: number
|
||||
error_rate: number
|
||||
cache_rate: number
|
||||
cache_rate_numerator: number
|
||||
cache_rate_denominator: number
|
||||
ttft: LatencyMetric
|
||||
duration: LatencyMetric
|
||||
upstream_affected_requests?: number
|
||||
upstream_attempt_count?: number
|
||||
}
|
||||
|
||||
export interface MonitorHealth {
|
||||
overall: HealthState
|
||||
error_rate: HealthState
|
||||
ttft: HealthState
|
||||
cache?: HealthState
|
||||
/** 0–100 blended score when samples are sufficient. */
|
||||
score?: number | null
|
||||
error_rate_score?: number | null
|
||||
ttft_score?: number | null
|
||||
cache_score?: number | null
|
||||
minimum_sample: number
|
||||
thresholds?: {
|
||||
minimum_sample?: number
|
||||
warning_error_rate: number
|
||||
critical_error_rate: number
|
||||
target_ttft_ms: number
|
||||
warning_ttft_ms: number
|
||||
critical_ttft_ms: number
|
||||
warning_cache_rate?: number
|
||||
critical_cache_rate?: number
|
||||
error_weight: number
|
||||
ttft_weight: number
|
||||
cache_weight?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** First-upgrade historical fill for 90m/24h/7d/30d; omitted when complete. */
|
||||
export interface MonitorBootstrap {
|
||||
active: boolean
|
||||
progress_percent: number
|
||||
covered_from?: string
|
||||
target_start?: string
|
||||
}
|
||||
|
||||
export interface MonitorCoverage {
|
||||
requested_start: string
|
||||
/** Exclusive upper bound of the UI-selected range (filter end). */
|
||||
requested_end?: string
|
||||
coverage_start: string
|
||||
data_through: string
|
||||
computed_at: string
|
||||
aggregation_lag_seconds: number
|
||||
coverage_complete: boolean
|
||||
bucket_seconds: number
|
||||
/** Present while initial aggregation has not covered the 30d product window. */
|
||||
bootstrap?: MonitorBootstrap | null
|
||||
}
|
||||
|
||||
export interface MonitorConfig {
|
||||
version: number
|
||||
enabled: boolean
|
||||
refresh_interval_seconds: 60 | 300
|
||||
platforms: Array<{ platform: string; enabled: boolean; models: string[] }>
|
||||
group_ids: number[]
|
||||
health_thresholds: {
|
||||
minimum_sample: number
|
||||
warning_error_rate: number
|
||||
critical_error_rate: number
|
||||
target_ttft_ms: number
|
||||
warning_ttft_ms: number
|
||||
critical_ttft_ms: number
|
||||
warning_cache_rate: number
|
||||
critical_cache_rate: number
|
||||
error_weight: number
|
||||
ttft_weight: number
|
||||
cache_weight: number
|
||||
}
|
||||
/** Categories excluded from error_rate / health; still listed in error breakdown. */
|
||||
ignored_error_categories?: string[]
|
||||
}
|
||||
|
||||
/** Ordered taxonomy mirrored from backend ChannelMonitorV2ErrorCategories. */
|
||||
export const MONITOR_ERROR_CATEGORIES = [
|
||||
'content_policy',
|
||||
'authentication',
|
||||
'context_limit',
|
||||
'invalid_request',
|
||||
'model_unsupported',
|
||||
'group_access',
|
||||
'quota_or_balance',
|
||||
'account_pool_unavailable',
|
||||
'rate_or_capacity',
|
||||
'timeout',
|
||||
'transport_or_stream',
|
||||
'upstream_forbidden',
|
||||
'not_found',
|
||||
'client_cancelled',
|
||||
'upstream_5xx',
|
||||
'internal',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export type MonitorErrorCategory = (typeof MONITOR_ERROR_CATEGORIES)[number]
|
||||
|
||||
export interface MonitorSnapshot {
|
||||
config: MonitorConfig
|
||||
coverage: MonitorCoverage
|
||||
metrics: MonitorMetric
|
||||
health: MonitorHealth
|
||||
trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }>
|
||||
}
|
||||
|
||||
export interface MonitorMatrixBucket {
|
||||
bucket_start: string
|
||||
metrics: MonitorMetric
|
||||
health: MonitorHealth
|
||||
}
|
||||
|
||||
export interface MonitorMatrixRow {
|
||||
platform: string
|
||||
group_id?: number
|
||||
group_name?: string
|
||||
model?: string
|
||||
metrics: MonitorMetric
|
||||
health: MonitorHealth
|
||||
buckets: MonitorMatrixBucket[]
|
||||
}
|
||||
|
||||
export interface MonitorMatrixResponse {
|
||||
coverage: MonitorCoverage
|
||||
group_by: MonitorMatrixGroupBy
|
||||
items: MonitorMatrixRow[]
|
||||
}
|
||||
|
||||
export interface MonitorDimensions {
|
||||
platforms: Array<{ value: string; label: string; request_count: number }>
|
||||
groups: Array<{ id: number; name: string; platform?: string; request_count: number }>
|
||||
models: Array<{ value: string; label: string; platform?: string; request_count: number }>
|
||||
}
|
||||
|
||||
export interface MonitorModelRow { platform: string; model: string; metrics: MonitorMetric; health: MonitorHealth }
|
||||
export interface MonitorErrorRow {
|
||||
category: string
|
||||
count: number
|
||||
rate: number
|
||||
details?: Array<{
|
||||
platform?: string
|
||||
model?: string
|
||||
error_type?: string
|
||||
status_code?: number
|
||||
upstream_status_code?: number
|
||||
message?: string
|
||||
count: number
|
||||
}>
|
||||
/** true when category is in config.ignored_error_categories */
|
||||
ignored?: boolean
|
||||
}
|
||||
export interface MonitorUserRow {
|
||||
user_id?: number
|
||||
rank: number
|
||||
email?: string
|
||||
username?: string
|
||||
display_label: string
|
||||
is_self: boolean
|
||||
can_drilldown: boolean
|
||||
metrics: MonitorMetric
|
||||
}
|
||||
|
||||
function params(filter: MonitorFilter) {
|
||||
return {
|
||||
range: filter.range,
|
||||
platform: filter.platforms.length ? filter.platforms : undefined,
|
||||
group_id: filter.groupIds.length ? filter.groupIds : undefined,
|
||||
model: filter.models.length ? filter.models : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function repeatedArrayParamsSerializer(values: Record<string, unknown>): string {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, rawValue] of Object.entries(values)) {
|
||||
if (rawValue == null || rawValue === '') continue
|
||||
const entries = Array.isArray(rawValue) ? rawValue : [rawValue]
|
||||
for (const value of entries) {
|
||||
if (value != null && value !== '') search.append(key, String(value))
|
||||
}
|
||||
}
|
||||
return search.toString()
|
||||
}
|
||||
|
||||
const requestConfig = (filter: MonitorFilter, signal?: AbortSignal, extraParams: Record<string, unknown> = {}) => ({
|
||||
params: { ...params(filter), ...extraParams },
|
||||
paramsSerializer: { serialize: repeatedArrayParamsSerializer },
|
||||
signal,
|
||||
})
|
||||
|
||||
function base(admin: boolean) { return admin ? '/admin/channel-monitor-v2' : '/channel-monitor-v2' }
|
||||
|
||||
export async function getDimensions(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
|
||||
const { data } = await apiClient.get<MonitorDimensions>(`${base(admin)}/dimensions`, requestConfig(filter, signal))
|
||||
return data
|
||||
}
|
||||
export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
|
||||
const { data } = await apiClient.get<MonitorSnapshot>(`${base(admin)}/snapshot`, requestConfig(filter, signal))
|
||||
return data
|
||||
}
|
||||
export async function getMatrix(filter: MonitorFilter, groupBy: MonitorMatrixGroupBy, admin = false, signal?: AbortSignal) {
|
||||
const { data } = await apiClient.get<MonitorMatrixResponse>(`${base(admin)}/matrix`, requestConfig(filter, signal, { group_by: groupBy }))
|
||||
return data
|
||||
}
|
||||
export async function getModels(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
|
||||
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorModelRow[] }>(`${base(admin)}/models`, requestConfig(filter, signal))
|
||||
return data
|
||||
}
|
||||
export async function getErrors(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
|
||||
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorErrorRow[] }>(`${base(admin)}/errors`, requestConfig(filter, signal))
|
||||
return data
|
||||
}
|
||||
export async function getUsers(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
|
||||
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorUserRow[] }>(`${base(admin)}/users`, requestConfig(filter, signal))
|
||||
return data
|
||||
}
|
||||
export async function getConfig() {
|
||||
const { data } = await apiClient.get<MonitorConfig>('/admin/channel-monitor-v2/config')
|
||||
return data
|
||||
}
|
||||
export async function updateConfig(config: MonitorConfig) {
|
||||
const { data } = await apiClient.put<MonitorConfig>('/admin/channel-monitor-v2/config', config)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* User Channels API endpoints (non-admin)
|
||||
* 用户侧「可用渠道」聚合查询:渠道 + 用户可访问的分组 + 支持模型(含定价)。
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { BillingMode } from '@/constants/channel'
|
||||
|
||||
export interface UserAvailableGroup {
|
||||
id: number
|
||||
name: string
|
||||
platform: string
|
||||
/** 'standard' | 'subscription' — 订阅分组视觉加深,和 API 密钥页保持一致。 */
|
||||
subscription_type: string
|
||||
/** 分组默认倍率。用户专属倍率(若有)通过 /groups/rates 获取后在前端 join。 */
|
||||
rate_multiplier: number
|
||||
peak_rate_enabled: boolean
|
||||
peak_start: string
|
||||
peak_end: string
|
||||
peak_rate_multiplier: number
|
||||
/** true = 专属分组(小范围授权);false = 公开分组。 */
|
||||
is_exclusive: boolean
|
||||
}
|
||||
|
||||
export interface UserPricingInterval {
|
||||
min_tokens: number
|
||||
max_tokens: number | null
|
||||
tier_label?: string
|
||||
input_price: number | null
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
per_request_price: number | null
|
||||
}
|
||||
|
||||
export interface UserSupportedModelPricing {
|
||||
billing_mode: BillingMode
|
||||
input_price: number | null
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: UserPricingInterval[]
|
||||
}
|
||||
|
||||
export interface UserSupportedModel {
|
||||
name: string
|
||||
platform: string
|
||||
pricing: UserSupportedModelPricing | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道下单个平台的子视图:用户可访问的分组 + 该平台支持的模型。
|
||||
* 后端把一个渠道按平台聚合成 sections,前端可以把渠道名作为 row-group
|
||||
* 一次渲染,后面按 sections 顺序用 rowspan 铺开。
|
||||
*/
|
||||
export interface UserChannelPlatformSection {
|
||||
platform: string
|
||||
groups: UserAvailableGroup[]
|
||||
supported_models: UserSupportedModel[]
|
||||
}
|
||||
|
||||
export interface UserAvailableChannel {
|
||||
name: string
|
||||
description: string
|
||||
platforms: UserChannelPlatformSection[]
|
||||
}
|
||||
|
||||
/** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */
|
||||
export async function getAvailable(options?: { signal?: AbortSignal }): Promise<UserAvailableChannel[]> {
|
||||
const { data } = await apiClient.get<UserAvailableChannel[]>('/channels/available', {
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const userChannelsAPI = { getAvailable }
|
||||
|
||||
export default userChannelsAPI
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Axios HTTP Client Configuration
|
||||
* Base client with interceptors for authentication, token refresh, and error handling
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import { getLocale } from '@/i18n'
|
||||
import {
|
||||
ADMIN_UI_REQUEST_HEADER,
|
||||
USER_UI_REQUEST_HEADER,
|
||||
shouldMarkAdminUIRequest,
|
||||
shouldMarkUserUIRequest,
|
||||
} from './adminUIRequest'
|
||||
import { refreshAuthTokens } from './tokenRefresh'
|
||||
import { getAPIBaseURL } from './url'
|
||||
export { buildApiUrl, buildGatewayUrl } from './url'
|
||||
|
||||
// ==================== Axios Instance Configuration ====================
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: getAPIBaseURL(),
|
||||
withCredentials: true,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Request Interceptor ====================
|
||||
|
||||
// Get user's timezone
|
||||
const getUserTimezone = (): string => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Attach token from localStorage
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Attach locale for backend translations
|
||||
if (config.headers) {
|
||||
config.headers['Accept-Language'] = getLocale()
|
||||
}
|
||||
|
||||
// Attach timezone for all GET requests (backend may use it for default date ranges)
|
||||
if (config.method === 'get') {
|
||||
if (!config.params) {
|
||||
config.params = {}
|
||||
}
|
||||
config.params.timezone = getUserTimezone()
|
||||
}
|
||||
|
||||
if (config.headers) {
|
||||
const requestURL = String(config.url || '')
|
||||
if (shouldMarkAdminUIRequest(requestURL)) {
|
||||
config.headers[ADMIN_UI_REQUEST_HEADER] = '1'
|
||||
}
|
||||
if (shouldMarkUserUIRequest(requestURL)) {
|
||||
config.headers[USER_UI_REQUEST_HEADER] = '1'
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== Response Interceptor ====================
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Unwrap standard API response format { code, message, data }
|
||||
const apiResponse = response.data as ApiResponse<unknown>
|
||||
if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) {
|
||||
if (apiResponse.code === 0) {
|
||||
// Success - return the data portion
|
||||
response.data = apiResponse.data
|
||||
} else {
|
||||
// API error
|
||||
const resp = apiResponse as unknown as Record<string, unknown>
|
||||
return Promise.reject({
|
||||
status: response.status,
|
||||
code: apiResponse.code,
|
||||
message: apiResponse.message || 'Unknown error',
|
||||
reason: resp.reason,
|
||||
metadata: resp.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error: AxiosError<ApiResponse<unknown>>) => {
|
||||
// Request cancellation: keep the original axios cancellation error so callers can ignore it.
|
||||
// Otherwise we'd misclassify it as a generic "network error".
|
||||
if (error.code === 'ERR_CANCELED' || axios.isCancel(error)) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// Handle common errors
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
const url = String(error.config?.url || '')
|
||||
|
||||
// Validate `data` shape to avoid HTML error pages breaking our error handling.
|
||||
const apiData = (typeof data === 'object' && data !== null ? data : {}) as Record<string, any>
|
||||
|
||||
// Ops monitoring disabled: treat as feature-flagged 404, and proactively redirect away
|
||||
// from ops pages to avoid broken UI states.
|
||||
if (status === 404 && apiData.message === 'Ops monitoring is disabled') {
|
||||
try {
|
||||
localStorage.setItem('ops_monitoring_enabled_cached', 'false')
|
||||
} catch {
|
||||
// ignore localStorage failures
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ops-monitoring-disabled'))
|
||||
} catch {
|
||||
// ignore event failures
|
||||
}
|
||||
|
||||
if (window.location.pathname.startsWith('/admin/ops')) {
|
||||
window.location.href = '/admin/settings'
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: 'OPS_DISABLED',
|
||||
message: apiData.message || error.message,
|
||||
url
|
||||
})
|
||||
}
|
||||
|
||||
if (status === 423 && apiData.code === 'ADMIN_COMPLIANCE_ACK_REQUIRED') {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('admin-compliance-required', {
|
||||
detail: apiData.metadata || {}
|
||||
}))
|
||||
} catch {
|
||||
// ignore event failures
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: apiData.code,
|
||||
message: apiData.message || error.message,
|
||||
metadata: apiData.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// 401: Try to refresh the token if we have a refresh token
|
||||
// This handles TOKEN_EXPIRED, INVALID_TOKEN, TOKEN_REVOKED, etc.
|
||||
if (status === 401 && !originalRequest._retry) {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
const isAuthEndpoint =
|
||||
url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh')
|
||||
|
||||
// If we have a refresh token and this is not an auth endpoint, try to refresh
|
||||
if (refreshToken && !isAuthEndpoint) {
|
||||
const refreshSessionUser = localStorage.getItem('auth_user')
|
||||
originalRequest._retry = true
|
||||
|
||||
try {
|
||||
const headers = originalRequest.headers as Record<string, unknown> | undefined
|
||||
const authHeader = headers?.Authorization ?? headers?.authorization
|
||||
const failedAccessToken =
|
||||
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice('Bearer '.length)
|
||||
: null
|
||||
const tokens = await refreshAuthTokens({ failedAccessToken })
|
||||
|
||||
// Retry the original request with the refreshed token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${tokens.access_token}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
} catch {
|
||||
// A stale request must never destroy a session that was logged out or replaced while
|
||||
// its refresh was in flight (for example, when another tab signs in as another user).
|
||||
const sessionChanged =
|
||||
localStorage.getItem('refresh_token') !== refreshToken ||
|
||||
localStorage.getItem('auth_user') !== refreshSessionUser
|
||||
if (sessionChanged) {
|
||||
return Promise.reject({
|
||||
status: 401,
|
||||
code: 'AUTH_SESSION_CHANGED',
|
||||
message: 'Authentication session changed while refreshing.'
|
||||
})
|
||||
}
|
||||
|
||||
// Clear tokens and redirect to login
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
localStorage.removeItem('token_expires_at')
|
||||
sessionStorage.setItem('auth_expired', '1')
|
||||
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
status: 401,
|
||||
code: 'TOKEN_REFRESH_FAILED',
|
||||
message: 'Session expired. Please log in again.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// No refresh token or is auth endpoint - clear auth and redirect
|
||||
const hasToken = !!localStorage.getItem('auth_token')
|
||||
const headers = error.config?.headers as Record<string, unknown> | undefined
|
||||
const authHeader = headers?.Authorization ?? headers?.authorization
|
||||
const sentAuth =
|
||||
typeof authHeader === 'string'
|
||||
? authHeader.trim() !== ''
|
||||
: Array.isArray(authHeader)
|
||||
? authHeader.length > 0
|
||||
: !!authHeader
|
||||
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
localStorage.removeItem('token_expires_at')
|
||||
if ((hasToken || sentAuth) && !isAuthEndpoint) {
|
||||
sessionStorage.setItem('auth_expired', '1')
|
||||
}
|
||||
// Only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
// Return structured error
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: apiData.code,
|
||||
reason: apiData.reason,
|
||||
error: apiData.error,
|
||||
message: apiData.message || apiData.detail || error.message,
|
||||
metadata: apiData.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// Network error
|
||||
return Promise.reject({
|
||||
status: 0,
|
||||
message: 'Network error. Please check your connection.'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* User Groups API endpoints (non-admin)
|
||||
* Handles group-related operations for regular users
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { Group } from '@/types'
|
||||
|
||||
/**
|
||||
* Get available groups that the current user can bind to API keys
|
||||
* This returns groups based on user's permissions:
|
||||
* - Standard groups: public (non-exclusive) or explicitly allowed
|
||||
* - Subscription groups: user has active subscription
|
||||
* @returns List of available groups
|
||||
*/
|
||||
export async function getAvailable(): Promise<Group[]> {
|
||||
const { data } = await apiClient.get<Group[]>('/groups/available')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's custom group rate multipliers
|
||||
* @returns Map of group_id to custom rate_multiplier
|
||||
*/
|
||||
export async function getUserGroupRates(): Promise<Record<number, number>> {
|
||||
const { data } = await apiClient.get<Record<number, number> | null>('/groups/rates')
|
||||
return data || {}
|
||||
}
|
||||
|
||||
export const userGroupsAPI = {
|
||||
getAvailable,
|
||||
getUserGroupRates
|
||||
}
|
||||
|
||||
export default userGroupsAPI
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* API Client for Sub2API Backend
|
||||
* Central export point for all API modules
|
||||
*/
|
||||
|
||||
// Re-export the HTTP client
|
||||
export { apiClient } from './client'
|
||||
|
||||
// Auth API
|
||||
export { authAPI, isTotp2FARequired, type LoginResponse } from './auth'
|
||||
|
||||
// User APIs
|
||||
export { keysAPI } from './keys'
|
||||
export { usageAPI } from './usage'
|
||||
export { userAPI } from './user'
|
||||
export { redeemAPI, type RedeemHistoryItem } from './redeem'
|
||||
export { paymentAPI } from './payment'
|
||||
export { userGroupsAPI } from './groups'
|
||||
export { userChannelsAPI } from './channels'
|
||||
export * as batchImageAPI from './batchImage'
|
||||
export { totpAPI } from './totp'
|
||||
export { passkeyAPI, type PasskeyCredentialSummary } from './passkey'
|
||||
export { default as announcementsAPI } from './announcements'
|
||||
export { channelMonitorUserAPI } from './channelMonitor'
|
||||
|
||||
// Admin APIs
|
||||
export { adminAPI } from './admin'
|
||||
|
||||
// Default export
|
||||
export { default } from './client'
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* API Keys management endpoints
|
||||
* Handles CRUD operations for user API keys
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedResponse } from '@/types'
|
||||
|
||||
/**
|
||||
* List all API keys for current user
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 10)
|
||||
* @param filters - Optional filter parameters
|
||||
* @param options - Optional request options
|
||||
* @returns Paginated list of API keys
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filters?: {
|
||||
search?: string
|
||||
status?: string
|
||||
group_id?: number | string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<ApiKey>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', {
|
||||
params: { page, page_size: pageSize, ...filters },
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key by ID
|
||||
* @param id - API key ID
|
||||
* @returns API key details
|
||||
*/
|
||||
export async function getById(id: number): Promise<ApiKey> {
|
||||
const { data } = await apiClient.get<ApiKey>(`/keys/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new API key
|
||||
* @param name - Key name
|
||||
* @param groupId - Optional group ID
|
||||
* @param customKey - Optional custom key value
|
||||
* @param ipWhitelist - Optional IP whitelist
|
||||
* @param ipBlacklist - Optional IP blacklist
|
||||
* @param quota - Optional quota limit in USD (0 = unlimited)
|
||||
* @param expiresInDays - Optional days until expiry (undefined = never expires)
|
||||
* @param rateLimitData - Optional rate limit fields
|
||||
* @returns Created API key
|
||||
*/
|
||||
export async function create(
|
||||
name: string,
|
||||
groupId?: number | null,
|
||||
customKey?: string,
|
||||
ipWhitelist?: string[],
|
||||
ipBlacklist?: string[],
|
||||
quota?: number,
|
||||
expiresInDays?: number,
|
||||
rateLimitData?: { rate_limit_5h?: number; rate_limit_1d?: number; rate_limit_7d?: number }
|
||||
): Promise<ApiKey> {
|
||||
const payload: CreateApiKeyRequest = { name }
|
||||
if (groupId !== undefined) {
|
||||
payload.group_id = groupId
|
||||
}
|
||||
if (customKey) {
|
||||
payload.custom_key = customKey
|
||||
}
|
||||
if (ipWhitelist && ipWhitelist.length > 0) {
|
||||
payload.ip_whitelist = ipWhitelist
|
||||
}
|
||||
if (ipBlacklist && ipBlacklist.length > 0) {
|
||||
payload.ip_blacklist = ipBlacklist
|
||||
}
|
||||
if (quota !== undefined && quota > 0) {
|
||||
payload.quota = quota
|
||||
}
|
||||
if (expiresInDays !== undefined && expiresInDays > 0) {
|
||||
payload.expires_in_days = expiresInDays
|
||||
}
|
||||
if (rateLimitData?.rate_limit_5h && rateLimitData.rate_limit_5h > 0) {
|
||||
payload.rate_limit_5h = rateLimitData.rate_limit_5h
|
||||
}
|
||||
if (rateLimitData?.rate_limit_1d && rateLimitData.rate_limit_1d > 0) {
|
||||
payload.rate_limit_1d = rateLimitData.rate_limit_1d
|
||||
}
|
||||
if (rateLimitData?.rate_limit_7d && rateLimitData.rate_limit_7d > 0) {
|
||||
payload.rate_limit_7d = rateLimitData.rate_limit_7d
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<ApiKey>('/keys', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API key
|
||||
* @param id - API key ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated API key
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateApiKeyRequest): Promise<ApiKey> {
|
||||
const { data } = await apiClient.put<ApiKey>(`/keys/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key
|
||||
* @param id - API key ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteKey(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/keys/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle API key status (active/inactive)
|
||||
* @param id - API key ID
|
||||
* @param status - New status
|
||||
* @returns Updated API key
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<ApiKey> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
export const keysAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteKey,
|
||||
toggleStatus
|
||||
}
|
||||
|
||||
export default keysAPI
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Model Plaza API(公开端点,可匿名访问)
|
||||
* 以分组为中心的模型价目:分组信息 + 模型渠道定价 + LiteLLM 官方参考价。
|
||||
* 带 token 请求时后端会额外返回专属分组与用户专属倍率。
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { UserSupportedModelPricing } from './channels'
|
||||
|
||||
/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */
|
||||
export interface PlazaOfficialPricing {
|
||||
input_price: number | null
|
||||
output_price: number | null
|
||||
/** 5m 缓存写入(= LiteLLM cache_creation)。 */
|
||||
cache_write_price: number | null
|
||||
/** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */
|
||||
cache_write_1h_price?: number | null
|
||||
cache_read_price: number | null
|
||||
}
|
||||
|
||||
export interface PlazaModel {
|
||||
name: string
|
||||
platform: string
|
||||
pricing: UserSupportedModelPricing | null
|
||||
official_pricing: PlazaOfficialPricing | null
|
||||
}
|
||||
|
||||
export interface ModelPlazaGroup {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
platform: string
|
||||
/** 'standard' | 'subscription' */
|
||||
subscription_type: string
|
||||
rate_multiplier: number
|
||||
/** 登录且管理员为该用户配了专属倍率时返回;生效倍率 = user_rate ?? rate_multiplier。 */
|
||||
user_rate_multiplier?: number
|
||||
peak_rate_enabled: boolean
|
||||
peak_start: string
|
||||
peak_end: string
|
||||
peak_rate_multiplier: number
|
||||
is_exclusive: boolean
|
||||
/** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */
|
||||
image_rate_independent: boolean
|
||||
image_rate_multiplier: number
|
||||
models: PlazaModel[]
|
||||
}
|
||||
|
||||
export interface ModelPlazaResponse {
|
||||
/** 管理员配置的全局价格说明(Markdown)。 */
|
||||
description: string
|
||||
groups: ModelPlazaGroup[]
|
||||
}
|
||||
|
||||
/** 获取模型广场数据。开关未启用时后端返回 404。 */
|
||||
export async function getModelPlaza(options?: { signal?: AbortSignal }): Promise<ModelPlazaResponse> {
|
||||
const { data } = await apiClient.get<ModelPlazaResponse>('/model-plaza', {
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const modelPlazaAPI = { getModelPlaza }
|
||||
|
||||
export default modelPlazaAPI
|
||||
@@ -0,0 +1,168 @@
|
||||
import { apiClient } from './client'
|
||||
import type { ActionCaptchaRequestProof, AuthResponse } from '@/types'
|
||||
|
||||
export interface PasskeyCredentialSummary {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
backup: boolean
|
||||
}
|
||||
|
||||
interface CeremonyOptionsResponse {
|
||||
session_token: string
|
||||
options: {
|
||||
publicKey: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function requirePasskeySupport(): void {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error('Passkeys are not supported by this browser')
|
||||
}
|
||||
}
|
||||
|
||||
function base64URLToBuffer(value: string): ArrayBuffer {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function bufferToBase64URL(value: ArrayBuffer | null): string | null {
|
||||
if (value === null) return null
|
||||
const bytes = new Uint8Array(value)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function creationOptionsFromJSON(
|
||||
value: Record<string, unknown>
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
|
||||
const user = { ...(options.user as Record<string, unknown>) }
|
||||
user.id = base64URLToBuffer(String(user.id))
|
||||
options.user = user
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
options.excludeCredentials = options.excludeCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialCreationOptions
|
||||
}
|
||||
|
||||
function requestOptionsFromJSON(
|
||||
value: Record<string, unknown>
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
options.allowCredentials = options.allowCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
function serializeRegistrationCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
attestationObject: bufferToBase64URL(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAssertionCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
authenticatorData: bufferToBase64URL(response.authenticatorData),
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
signature: bufferToBase64URL(response.signature),
|
||||
userHandle: bufferToBase64URL(response.userHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login(proof?: ActionCaptchaRequestProof): Promise<AuthResponse> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = proof
|
||||
? await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin', proof)
|
||||
: await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin')
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: requestOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey sign-in was cancelled')
|
||||
}
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/passkey/login/finish', {
|
||||
session_token: begin.session_token,
|
||||
credential: serializeAssertionCredential(credential)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function register(name: string, password: string): Promise<PasskeyCredentialSummary> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/user/passkeys/register/begin',
|
||||
{ password }
|
||||
)
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: creationOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey creation was cancelled')
|
||||
}
|
||||
const { data } = await apiClient.post<PasskeyCredentialSummary>(
|
||||
'/user/passkeys/register/finish',
|
||||
{
|
||||
session_token: begin.session_token,
|
||||
name,
|
||||
credential: serializeRegistrationCredential(credential)
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
async function list(): Promise<PasskeyCredentialSummary[]> {
|
||||
const { data } = await apiClient.get<PasskeyCredentialSummary[]>('/user/passkeys')
|
||||
return data
|
||||
}
|
||||
|
||||
async function rename(id: number, name: string): Promise<void> {
|
||||
await apiClient.patch(`/user/passkeys/${id}`, { name })
|
||||
}
|
||||
|
||||
async function remove(id: number, password: string): Promise<void> {
|
||||
await apiClient.delete(`/user/passkeys/${id}`, { data: { password } })
|
||||
}
|
||||
|
||||
export const passkeyAPI = {
|
||||
isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials),
|
||||
login,
|
||||
register,
|
||||
list,
|
||||
rename,
|
||||
remove
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* User Payment API endpoints
|
||||
* Handles payment operations for regular users
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
PaymentConfig,
|
||||
SubscriptionPlan,
|
||||
MethodLimitsResponse,
|
||||
CheckoutInfoResponse,
|
||||
CreateOrderRequest,
|
||||
CreateOrderResult,
|
||||
PaymentOrder
|
||||
} from '@/types/payment'
|
||||
import type { BasePaginationResponse } from '@/types'
|
||||
|
||||
export interface PublicOrderVerifyResult {
|
||||
out_trade_no: string
|
||||
status: string
|
||||
paid: boolean
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export const paymentAPI = {
|
||||
/** Get payment configuration (enabled types, limits, etc.) */
|
||||
getConfig() {
|
||||
return apiClient.get<PaymentConfig>('/payment/config')
|
||||
},
|
||||
|
||||
/** Get available subscription plans */
|
||||
getPlans() {
|
||||
return apiClient.get<SubscriptionPlan[]>('/payment/plans')
|
||||
},
|
||||
|
||||
/** Get all checkout page data in a single call */
|
||||
getCheckoutInfo() {
|
||||
return apiClient.get<CheckoutInfoResponse>('/payment/checkout-info')
|
||||
},
|
||||
|
||||
/** Get payment method limits and fee rates */
|
||||
getLimits() {
|
||||
return apiClient.get<MethodLimitsResponse>('/payment/limits')
|
||||
},
|
||||
|
||||
/** Create a new payment order */
|
||||
createOrder(data: CreateOrderRequest) {
|
||||
return apiClient.post<CreateOrderResult>('/payment/orders', data)
|
||||
},
|
||||
|
||||
/** Get current user's orders */
|
||||
getMyOrders(params?: { page?: number; page_size?: number; status?: string }) {
|
||||
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/payment/orders/my', { params })
|
||||
},
|
||||
|
||||
/** Get a specific order by ID */
|
||||
getOrder(id: number) {
|
||||
return apiClient.get<PaymentOrder>(`/payment/orders/${id}`)
|
||||
},
|
||||
|
||||
/** Cancel a pending order */
|
||||
cancelOrder(id: number) {
|
||||
return apiClient.post(`/payment/orders/${id}/cancel`)
|
||||
},
|
||||
|
||||
/** Verify order payment status with upstream provider */
|
||||
verifyOrder(outTradeNo: string) {
|
||||
return apiClient.post<PaymentOrder>('/payment/orders/verify', { out_trade_no: outTradeNo })
|
||||
},
|
||||
|
||||
/** Legacy-compatible public order lookup by out_trade_no */
|
||||
verifyOrderPublic(outTradeNo: string) {
|
||||
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
|
||||
},
|
||||
|
||||
/** Resolve an order from a signed resume token without auth */
|
||||
resolveOrderPublicByResumeToken(resumeToken: string) {
|
||||
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/resolve', { resume_token: resumeToken })
|
||||
},
|
||||
|
||||
/** Request a refund for a completed order */
|
||||
requestRefund(id: number, data: { reason: string }) {
|
||||
return apiClient.post(`/payment/orders/${id}/refund-request`, data)
|
||||
},
|
||||
|
||||
/** Get provider instance IDs that allow user refund */
|
||||
getRefundEligibleProviders() {
|
||||
return apiClient.get<{ provider_instance_ids: string[] }>('/payment/orders/refund-eligible-providers')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Redeem code API endpoints
|
||||
* Handles redeem code redemption for users
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { RedeemCodeRequest } from '@/types'
|
||||
|
||||
export interface RedeemHistoryItem {
|
||||
id: number
|
||||
code: string
|
||||
type: string
|
||||
value: number
|
||||
status: string
|
||||
used_at: string
|
||||
created_at: string
|
||||
// Notes from admin for admin_balance/admin_concurrency types
|
||||
notes?: string
|
||||
// Subscription-specific fields
|
||||
group_id?: number
|
||||
validity_days?: number
|
||||
group?: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a code
|
||||
* @param code - Redeem code string
|
||||
* @returns Redemption result with updated balance or concurrency
|
||||
*/
|
||||
export async function redeem(code: string): Promise<{
|
||||
message: string
|
||||
type: string
|
||||
value: number
|
||||
new_balance?: number
|
||||
new_concurrency?: number
|
||||
}> {
|
||||
const payload: RedeemCodeRequest = { code }
|
||||
|
||||
const { data } = await apiClient.post<{
|
||||
message: string
|
||||
type: string
|
||||
value: number
|
||||
new_balance?: number
|
||||
new_concurrency?: number
|
||||
}>('/redeem', payload)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's redemption history
|
||||
* @returns List of redeemed codes
|
||||
*/
|
||||
export async function getHistory(): Promise<RedeemHistoryItem[]> {
|
||||
const { data } = await apiClient.get<RedeemHistoryItem[]>('/redeem/history')
|
||||
return data
|
||||
}
|
||||
|
||||
export const redeemAPI = {
|
||||
redeem,
|
||||
getHistory
|
||||
}
|
||||
|
||||
export default redeemAPI
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Setup API endpoints
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import { buildGatewayUrl } from './url'
|
||||
|
||||
// Create a separate client for setup endpoints (not under /api/v1)
|
||||
const setupClient = axios.create({
|
||||
baseURL: buildGatewayUrl('/').replace(/\/+$/, ''),
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
export interface SetupStatus {
|
||||
needs_setup: boolean
|
||||
step: string
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
dbname: string
|
||||
sslmode: string
|
||||
}
|
||||
|
||||
export interface RedisConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
db: number
|
||||
enable_tls: boolean
|
||||
}
|
||||
|
||||
export interface AdminConfig {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string
|
||||
port: number
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface InstallRequest {
|
||||
database: DatabaseConfig
|
||||
redis: RedisConfig
|
||||
admin: AdminConfig
|
||||
server: ServerConfig
|
||||
}
|
||||
|
||||
export interface InstallResponse {
|
||||
message: string
|
||||
restart: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get setup status
|
||||
*/
|
||||
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||
const response = await setupClient.get('/setup/status')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
*/
|
||||
export async function testDatabase(config: DatabaseConfig): Promise<void> {
|
||||
await setupClient.post('/setup/test-db', config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Redis connection
|
||||
*/
|
||||
export async function testRedis(config: RedisConfig): Promise<void> {
|
||||
await setupClient.post('/setup/test-redis', config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform installation
|
||||
*/
|
||||
export async function install(config: InstallRequest): Promise<InstallResponse> {
|
||||
const response = await setupClient.post('/setup/install', config)
|
||||
return response.data.data
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* User Subscription API
|
||||
* API for regular users to view their own subscriptions and progress
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { UserSubscription, SubscriptionProgress } from '@/types'
|
||||
|
||||
/**
|
||||
* Subscription summary for user dashboard
|
||||
*/
|
||||
export interface SubscriptionSummary {
|
||||
active_count: number
|
||||
subscriptions: Array<{
|
||||
id: number
|
||||
group_name: string
|
||||
status: string
|
||||
daily_progress: number | null
|
||||
weekly_progress: number | null
|
||||
monthly_progress: number | null
|
||||
expires_at: string | null
|
||||
days_remaining: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of current user's subscriptions
|
||||
*/
|
||||
export async function getMySubscriptions(): Promise<UserSubscription[]> {
|
||||
const response = await apiClient.get<UserSubscription[]>('/subscriptions')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's active subscriptions
|
||||
*/
|
||||
export async function getActiveSubscriptions(): Promise<UserSubscription[]> {
|
||||
const response = await apiClient.get<UserSubscription[]>('/subscriptions/active')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for all user's active subscriptions
|
||||
*/
|
||||
export async function getSubscriptionsProgress(): Promise<SubscriptionProgress[]> {
|
||||
const response = await apiClient.get<SubscriptionProgress[]>('/subscriptions/progress')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription summary for dashboard display
|
||||
*/
|
||||
export async function getSubscriptionSummary(): Promise<SubscriptionSummary> {
|
||||
const response = await apiClient.get<SubscriptionSummary>('/subscriptions/summary')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a specific subscription
|
||||
*/
|
||||
export async function getSubscriptionProgress(
|
||||
subscriptionId: number
|
||||
): Promise<SubscriptionProgress> {
|
||||
const response = await apiClient.get<SubscriptionProgress>(
|
||||
`/subscriptions/${subscriptionId}/progress`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export default {
|
||||
getMySubscriptions,
|
||||
getActiveSubscriptions,
|
||||
getSubscriptionsProgress,
|
||||
getSubscriptionSummary,
|
||||
getSubscriptionProgress
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import axios from 'axios'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import { getAPIBaseURL } from './url'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
const AUTH_USER_KEY = 'auth_user'
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||
const TOKEN_EXPIRES_AT_KEY = 'token_expires_at'
|
||||
const TOKEN_REFRESH_LOCK_NAME = 'sub2api-auth-token-refresh'
|
||||
const TOKEN_REFRESH_TIMEOUT_MS = 30_000
|
||||
const TOKEN_REFRESH_BUFFER_MS = 120_000
|
||||
const PEER_REFRESH_WAIT_MS = 1_000
|
||||
const PEER_REFRESH_GRACE_MS = 1_000
|
||||
const PEER_REFRESH_POLL_MS = 25
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface RefreshAuthTokensOptions {
|
||||
/** Access token attached to the request that received a 401 response. */
|
||||
failedAccessToken?: string | null
|
||||
}
|
||||
|
||||
interface AuthSnapshot {
|
||||
accessToken: string | null
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
userID: number | null
|
||||
}
|
||||
|
||||
let inFlightRefresh: Promise<RefreshTokenResponse> | null = null
|
||||
|
||||
function getStoredUserID(): number | null {
|
||||
const rawUser = localStorage.getItem(AUTH_USER_KEY)
|
||||
if (!rawUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const id = Number((JSON.parse(rawUser) as { id?: unknown }).id)
|
||||
return Number.isFinite(id) && id > 0 ? id : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readAuthSnapshot(): AuthSnapshot {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: localStorage.getItem(AUTH_TOKEN_KEY),
|
||||
refreshToken,
|
||||
expiresAt: Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)),
|
||||
userID: getStoredUserID()
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredTokenPair(snapshot: AuthSnapshot): RefreshTokenResponse | null {
|
||||
const accessToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY))
|
||||
|
||||
if (
|
||||
!accessToken ||
|
||||
!refreshToken ||
|
||||
!Number.isFinite(expiresAt) ||
|
||||
expiresAt <= Date.now() ||
|
||||
getStoredUserID() !== snapshot.userID
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in: Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)),
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
}
|
||||
|
||||
function readPeerRefreshResult(
|
||||
snapshot: AuthSnapshot,
|
||||
failedAccessToken?: string | null
|
||||
): RefreshTokenResponse | null {
|
||||
const storedPair = readStoredTokenPair(snapshot)
|
||||
if (!storedPair) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (storedPair.refresh_token !== snapshot.refreshToken) {
|
||||
return storedPair
|
||||
}
|
||||
|
||||
if (
|
||||
failedAccessToken &&
|
||||
snapshot.accessToken !== failedAccessToken &&
|
||||
storedPair.access_token === snapshot.accessToken
|
||||
) {
|
||||
return storedPair
|
||||
}
|
||||
|
||||
if (!failedAccessToken) {
|
||||
const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY))
|
||||
if (
|
||||
expiresAt === snapshot.expiresAt &&
|
||||
storedPair.access_token === snapshot.accessToken &&
|
||||
expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS
|
||||
) {
|
||||
return storedPair
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function waitForPeerRefresh(
|
||||
snapshot: AuthSnapshot,
|
||||
failedAccessToken?: string | null,
|
||||
deadline = Date.now() + PEER_REFRESH_WAIT_MS
|
||||
): Promise<RefreshTokenResponse | null> {
|
||||
while (Date.now() < deadline) {
|
||||
const peerResult = readPeerRefreshResult(snapshot, failedAccessToken)
|
||||
if (peerResult) {
|
||||
return peerResult
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, PEER_REFRESH_POLL_MS))
|
||||
}
|
||||
|
||||
return readPeerRefreshResult(snapshot, failedAccessToken)
|
||||
}
|
||||
|
||||
function persistTokenPair(tokens: RefreshTokenResponse): void {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, tokens.access_token)
|
||||
localStorage.setItem(TOKEN_EXPIRES_AT_KEY, String(Date.now() + tokens.expires_in * 1000))
|
||||
// The rotating refresh token is written last so other tabs can treat its change as a commit marker.
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
|
||||
}
|
||||
|
||||
async function requestTokenPair(
|
||||
snapshot: AuthSnapshot,
|
||||
failedAccessToken?: string | null,
|
||||
mayHaveUncoordinatedPeer = false
|
||||
): Promise<RefreshTokenResponse> {
|
||||
// If this request loses a one-time-token race, the winning peer can legitimately take as long
|
||||
// as our own HTTP timeout to publish its replacement token. Keep the recovery window tied to
|
||||
// that timeout instead of an arbitrary short delay.
|
||||
const peerRefreshDeadline = Date.now() + TOKEN_REFRESH_TIMEOUT_MS + PEER_REFRESH_GRACE_MS
|
||||
|
||||
try {
|
||||
const response = await axios.post<ApiResponse<RefreshTokenResponse>>(
|
||||
`${getAPIBaseURL()}/auth/refresh`,
|
||||
{ refresh_token: snapshot.refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' }, timeout: TOKEN_REFRESH_TIMEOUT_MS }
|
||||
)
|
||||
const payload = response.data
|
||||
if (payload.code !== 0 || !payload.data) {
|
||||
throw new Error(payload.message || 'Token refresh failed')
|
||||
}
|
||||
|
||||
if (
|
||||
localStorage.getItem(REFRESH_TOKEN_KEY) !== snapshot.refreshToken ||
|
||||
getStoredUserID() !== snapshot.userID
|
||||
) {
|
||||
const peerResult = readPeerRefreshResult(snapshot, failedAccessToken)
|
||||
if (peerResult) {
|
||||
return peerResult
|
||||
}
|
||||
throw new Error('Session changed during token refresh')
|
||||
}
|
||||
|
||||
persistTokenPair(payload.data)
|
||||
return payload.data
|
||||
} catch (error) {
|
||||
// A peer tab may have rotated the one-time refresh token while this request was in flight.
|
||||
// A 4xx response can arrive quickly while the winning peer's response is still in flight, so
|
||||
// wait through the shared request deadline before treating the session as expired. Transient
|
||||
// non-4xx failures retain the short reconciliation window.
|
||||
const responseStatus = (error as { response?: { status?: unknown } }).response?.status
|
||||
const isTokenRejection =
|
||||
typeof responseStatus === 'number' && responseStatus >= 400 && responseStatus < 500
|
||||
const peerResult = await waitForPeerRefresh(
|
||||
snapshot,
|
||||
failedAccessToken,
|
||||
isTokenRejection && mayHaveUncoordinatedPeer
|
||||
? peerRefreshDeadline
|
||||
: Date.now() + PEER_REFRESH_WAIT_MS
|
||||
)
|
||||
if (peerResult) {
|
||||
return peerResult
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function runRefresh(options: RefreshAuthTokensOptions): Promise<RefreshTokenResponse> {
|
||||
const snapshot = readAuthSnapshot()
|
||||
const refresh = async (mayHaveUncoordinatedPeer = false): Promise<RefreshTokenResponse> => {
|
||||
const peerResult = readPeerRefreshResult(snapshot, options.failedAccessToken)
|
||||
if (peerResult) {
|
||||
return peerResult
|
||||
}
|
||||
return requestTokenPair(snapshot, options.failedAccessToken, mayHaveUncoordinatedPeer)
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.locks) {
|
||||
return navigator.locks.request(TOKEN_REFRESH_LOCK_NAME, () => refresh(false))
|
||||
}
|
||||
|
||||
return refresh(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh and persist the browser session.
|
||||
*
|
||||
* Calls in the same document share one promise. Web Locks serialize refreshes across tabs, while
|
||||
* the token snapshot check adopts a peer's newly rotated token instead of logging the user out.
|
||||
*/
|
||||
export function refreshAuthTokens(
|
||||
options: RefreshAuthTokensOptions = {}
|
||||
): Promise<RefreshTokenResponse> {
|
||||
if (inFlightRefresh) {
|
||||
return inFlightRefresh
|
||||
}
|
||||
|
||||
const pending = runRefresh(options)
|
||||
inFlightRefresh = pending
|
||||
const clearPending = (): void => {
|
||||
if (inFlightRefresh === pending) {
|
||||
inFlightRefresh = null
|
||||
}
|
||||
}
|
||||
void pending.then(clearPending, clearPending)
|
||||
return pending
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* TOTP (2FA) API endpoints
|
||||
* Handles Two-Factor Authentication with Google Authenticator
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
TotpStatus,
|
||||
TotpSetupRequest,
|
||||
TotpSetupResponse,
|
||||
TotpEnableRequest,
|
||||
TotpEnableResponse,
|
||||
TotpDisableRequest,
|
||||
TotpVerificationMethod
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Get TOTP status for current user
|
||||
* @returns TOTP status including enabled state and feature availability
|
||||
*/
|
||||
export async function getStatus(): Promise<TotpStatus> {
|
||||
const { data } = await apiClient.get<TotpStatus>('/user/totp/status')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get verification method for TOTP operations
|
||||
* @returns Method ('email' or 'password') required for setup/disable
|
||||
*/
|
||||
export async function getVerificationMethod(): Promise<TotpVerificationMethod> {
|
||||
const { data } = await apiClient.get<TotpVerificationMethod>('/user/totp/verification-method')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification code for TOTP operations
|
||||
* @returns Success response
|
||||
*/
|
||||
export async function sendVerifyCode(): Promise<{ success: boolean }> {
|
||||
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/send-code')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate TOTP setup - generates secret and QR code
|
||||
* @param request - Email code or password depending on verification method
|
||||
* @returns Setup response with secret, QR code URL, and setup token
|
||||
*/
|
||||
export async function initiateSetup(request?: TotpSetupRequest): Promise<TotpSetupResponse> {
|
||||
const { data } = await apiClient.post<TotpSetupResponse>('/user/totp/setup', request || {})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete TOTP setup by verifying the code
|
||||
* @param request - TOTP code and setup token
|
||||
* @returns Enable response with success status and enabled timestamp
|
||||
*/
|
||||
export async function enable(request: TotpEnableRequest): Promise<TotpEnableResponse> {
|
||||
const { data } = await apiClient.post<TotpEnableResponse>('/user/totp/enable', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable TOTP for current user
|
||||
* @param request - Email code or password depending on verification method
|
||||
* @returns Success response
|
||||
*/
|
||||
export async function disable(request: TotpDisableRequest): Promise<{ success: boolean }> {
|
||||
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/disable', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-up verification response
|
||||
*/
|
||||
export interface TotpStepUpResponse {
|
||||
verified: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TOTP code to grant the current session a short-lived step-up
|
||||
* (sudo) window for sensitive operations (account export, DB backup download...).
|
||||
* @param code - 6-digit TOTP code
|
||||
*/
|
||||
export async function stepUp(code: string): Promise<TotpStepUpResponse> {
|
||||
const { data } = await apiClient.post<TotpStepUpResponse>('/user/totp/step-up', { code })
|
||||
return data
|
||||
}
|
||||
|
||||
export const totpAPI = {
|
||||
getStatus,
|
||||
getVerificationMethod,
|
||||
sendVerifyCode,
|
||||
initiateSetup,
|
||||
enable,
|
||||
disable,
|
||||
stepUp
|
||||
}
|
||||
|
||||
export default totpAPI
|
||||
@@ -0,0 +1,43 @@
|
||||
const DEFAULT_API_BASE_URL = '/api/v1'
|
||||
const API_BASE_URL = normalizeAPIBaseURL(import.meta.env.VITE_API_BASE_URL)
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function normalizeAPIBaseURL(value: unknown): string {
|
||||
const raw = String(value || DEFAULT_API_BASE_URL).trim() || DEFAULT_API_BASE_URL
|
||||
const withoutTrailingSlash = raw.replace(/\/+$/, '')
|
||||
if (/^[a-z][a-z\d+.-]*:\/\//i.test(withoutTrailingSlash) || withoutTrailingSlash.startsWith('//')) {
|
||||
return withoutTrailingSlash
|
||||
}
|
||||
return normalizePath(withoutTrailingSlash)
|
||||
}
|
||||
|
||||
export function getAPIBaseURL(): string {
|
||||
return API_BASE_URL
|
||||
}
|
||||
|
||||
export function buildApiUrl(path: string): string {
|
||||
const base = getAPIBaseURL().replace(/\/+$/, '')
|
||||
let suffix = normalizePath(path)
|
||||
if (suffix === DEFAULT_API_BASE_URL) {
|
||||
suffix = ''
|
||||
} else if (suffix.startsWith(`${DEFAULT_API_BASE_URL}/`)) {
|
||||
suffix = suffix.slice(DEFAULT_API_BASE_URL.length)
|
||||
}
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
export function buildGatewayUrl(path: string): string {
|
||||
const suffix = normalizePath(path)
|
||||
try {
|
||||
const origin =
|
||||
typeof window === 'undefined'
|
||||
? new URL(getAPIBaseURL()).origin
|
||||
: new URL(getAPIBaseURL(), window.location.origin).origin
|
||||
return `${origin}${suffix}`
|
||||
} catch {
|
||||
return suffix
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Usage tracking API endpoints
|
||||
* Handles usage logs and statistics retrieval
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
UsageLog,
|
||||
UsageQueryParams,
|
||||
UsageStatsResponse,
|
||||
PaginatedResponse,
|
||||
TrendDataPoint,
|
||||
ModelStat,
|
||||
GroupStat,
|
||||
UsageRequestType,
|
||||
UserErrorRequest,
|
||||
UserErrorRequestDetail,
|
||||
UserErrorListParams
|
||||
} from '@/types'
|
||||
|
||||
// ==================== Dashboard Types ====================
|
||||
|
||||
export interface PlatformDashboardStats {
|
||||
platform: string
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_actual_cost: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
today_actual_cost: number
|
||||
}
|
||||
|
||||
export interface UserDashboardStats {
|
||||
total_api_keys: number
|
||||
active_api_keys: number
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_cache_creation_tokens: number
|
||||
total_cache_read_tokens: number
|
||||
total_tokens: number
|
||||
total_cost: number // 标准计费
|
||||
total_actual_cost: number // 实际扣除
|
||||
today_requests: number
|
||||
today_input_tokens: number
|
||||
today_output_tokens: number
|
||||
today_cache_creation_tokens: number
|
||||
today_cache_read_tokens: number
|
||||
today_tokens: number
|
||||
today_cost: number // 今日标准计费
|
||||
today_actual_cost: number // 今日实际扣除
|
||||
average_duration_ms: number
|
||||
rpm: number // 近5分钟平均每分钟请求数
|
||||
tpm: number // 近5分钟平均每分钟Token数
|
||||
by_platform?: PlatformDashboardStats[]
|
||||
}
|
||||
|
||||
export interface TrendParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
granularity?: 'day' | 'hour'
|
||||
api_key_id?: number
|
||||
model?: string
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
billing_mode?: string | null
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
trend: TrendDataPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
models: ModelStat[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export interface ApiKeyDailyUsagePoint {
|
||||
date: string
|
||||
requests: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_write_tokens: number
|
||||
total_tokens: number
|
||||
cost: number
|
||||
actual_cost: number
|
||||
}
|
||||
|
||||
export interface ApiKeyDailyUsageResponse {
|
||||
items: ApiKeyDailyUsagePoint[]
|
||||
days: number
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export interface UsageDashboardSnapshotV2Params extends TrendParams {
|
||||
include_trend?: boolean
|
||||
include_model_stats?: boolean
|
||||
include_group_stats?: boolean
|
||||
}
|
||||
|
||||
export interface UsageDashboardSnapshotV2Response {
|
||||
generated_at: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
trend?: TrendDataPoint[]
|
||||
models?: ModelStat[]
|
||||
groups?: GroupStat[]
|
||||
}
|
||||
|
||||
/**
|
||||
* List usage logs with optional filters
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param apiKeyId - Filter by API key ID
|
||||
* @returns Paginated list of usage logs
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
apiKeyId?: number
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const params: UsageQueryParams = {
|
||||
page,
|
||||
page_size: pageSize
|
||||
}
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage logs with advanced query parameters
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Paginated list of usage logs
|
||||
*/
|
||||
export async function query(
|
||||
params: UsageQueryParams & { sort_by?: string; sort_order?: 'asc' | 'desc' },
|
||||
config: { signal?: AbortSignal } = {}
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
...config,
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage statistics for a specific period
|
||||
* @param period - Time period ('today', 'week', 'month', 'year')
|
||||
* @param apiKeyId - Optional API key ID filter
|
||||
* @returns Usage statistics
|
||||
*/
|
||||
export async function getStats(
|
||||
paramsOrPeriod: (UsageQueryParams & { period?: string; timezone?: string }) | string = 'today',
|
||||
apiKeyId?: number
|
||||
): Promise<UsageStatsResponse> {
|
||||
const params: Record<string, unknown> = typeof paramsOrPeriod === 'string'
|
||||
? { period: paramsOrPeriod }
|
||||
: { ...paramsOrPeriod }
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage statistics for a date range
|
||||
* @param startDate - Start date (YYYY-MM-DD format)
|
||||
* @param endDate - End date (YYYY-MM-DD format)
|
||||
* @param apiKeyId - Optional API key ID filter
|
||||
* @returns Usage statistics
|
||||
*/
|
||||
export async function getStatsByDateRange(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
apiKeyId?: number
|
||||
): Promise<UsageStatsResponse> {
|
||||
const params: Record<string, unknown> = {
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage by date range
|
||||
* @param startDate - Start date (YYYY-MM-DD format)
|
||||
* @param endDate - End date (YYYY-MM-DD format)
|
||||
* @param apiKeyId - Optional API key ID filter
|
||||
* @returns Usage logs within date range
|
||||
*/
|
||||
export async function getByDateRange(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
apiKeyId?: number
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const params: UsageQueryParams = {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
page: 1,
|
||||
page_size: 100
|
||||
}
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed usage log by ID
|
||||
* @param id - Usage log ID
|
||||
* @returns Usage log details
|
||||
*/
|
||||
export async function getById(id: number): Promise<UsageLog> {
|
||||
const { data } = await apiClient.get<UsageLog>(`/usage/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
// ==================== Dashboard API ====================
|
||||
|
||||
/**
|
||||
* Get user dashboard statistics
|
||||
* @returns Dashboard statistics for current user
|
||||
*/
|
||||
export async function getDashboardStats(): Promise<UserDashboardStats> {
|
||||
const { data } = await apiClient.get<UserDashboardStats>('/usage/dashboard/stats')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user usage trend data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Usage trend data for current user
|
||||
*/
|
||||
export async function getDashboardTrend(params?: TrendParams): Promise<TrendResponse> {
|
||||
const { data } = await apiClient.get<TrendResponse>('/usage/dashboard/trend', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user model usage statistics
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Model usage statistics for current user
|
||||
*/
|
||||
export async function getDashboardModels(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
api_key_id?: number
|
||||
model?: string
|
||||
model_source?: 'requested'
|
||||
group_id?: number
|
||||
request_type?: UsageRequestType
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
billing_mode?: string | null
|
||||
timezone?: string
|
||||
}): Promise<ModelStatsResponse> {
|
||||
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily usage details for one API key owned by the current user.
|
||||
* @param apiKeyId - API key ID
|
||||
* @param days - Number of days to include (1-90)
|
||||
* @returns Daily usage detail rows
|
||||
*/
|
||||
export async function getMyApiKeyDailyUsage(
|
||||
apiKeyId: number,
|
||||
days: number = 30
|
||||
): Promise<ApiKeyDailyUsageResponse> {
|
||||
const { data } = await apiClient.get<ApiKeyDailyUsageResponse>(
|
||||
`/user/api-keys/${apiKeyId}/usage/daily`,
|
||||
{ params: { days } }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getDashboardSnapshotV2(
|
||||
params?: UsageDashboardSnapshotV2Params
|
||||
): Promise<UsageDashboardSnapshotV2Response> {
|
||||
const { data } = await apiClient.get<UsageDashboardSnapshotV2Response>(
|
||||
'/usage/dashboard/snapshot-v2',
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchApiKeyUsageStats {
|
||||
api_key_id: number
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
}
|
||||
|
||||
export interface BatchApiKeysUsageResponse {
|
||||
stats: Record<string, BatchApiKeyUsageStats>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch usage stats for user's own API keys
|
||||
* @param apiKeyIds - Array of API key IDs
|
||||
* @param options - Optional request options
|
||||
* @returns Usage stats map keyed by API key ID
|
||||
*/
|
||||
export async function getDashboardApiKeysUsage(
|
||||
apiKeyIds: number[],
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<BatchApiKeysUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
|
||||
'/usage/dashboard/api-keys-usage',
|
||||
{
|
||||
api_key_ids: apiKeyIds
|
||||
},
|
||||
{
|
||||
signal: options?.signal
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listMyErrorRequests(
|
||||
params: UserErrorListParams
|
||||
): Promise<PaginatedResponse<UserErrorRequest>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserErrorRequest>>('/usage/errors', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getMyErrorDetail(id: number): Promise<UserErrorRequestDetail> {
|
||||
const { data } = await apiClient.get<UserErrorRequestDetail>(`/usage/errors/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const usageAPI = {
|
||||
list,
|
||||
query,
|
||||
getStats,
|
||||
getStatsByDateRange,
|
||||
getByDateRange,
|
||||
getById,
|
||||
// Dashboard
|
||||
getDashboardStats,
|
||||
getDashboardTrend,
|
||||
getDashboardModels,
|
||||
getMyApiKeyDailyUsage,
|
||||
getDashboardSnapshotV2,
|
||||
getDashboardApiKeysUsage,
|
||||
// Error requests
|
||||
listMyErrorRequests,
|
||||
getMyErrorDetail
|
||||
}
|
||||
|
||||
export default usageAPI
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* User API endpoints
|
||||
* Handles user profile management and password changes
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import {
|
||||
resolveWeChatOAuthStartStrict,
|
||||
prepareOAuthBindAccessTokenCookie,
|
||||
type WeChatOAuthPublicSettings,
|
||||
} from './auth'
|
||||
import type {
|
||||
User,
|
||||
ChangePasswordRequest,
|
||||
NotifyEmailEntry,
|
||||
UserAuthProvider,
|
||||
UserAffiliateDetail,
|
||||
AffiliateTransferResponse,
|
||||
PlatformQuotasResponse,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* @returns User profile data
|
||||
*/
|
||||
export async function getProfile(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/user/profile')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user profile
|
||||
* @param profile - Profile data to update
|
||||
* @returns Updated user profile data
|
||||
*/
|
||||
export async function updateProfile(profile: {
|
||||
username?: string
|
||||
avatar_url?: string | null
|
||||
balance_notify_enabled?: boolean
|
||||
balance_notify_threshold?: number | null
|
||||
balance_notify_extra_emails?: NotifyEmailEntry[]
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user', profile)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Change current user password
|
||||
* @param passwords - Old and new password
|
||||
* @returns Success message
|
||||
*/
|
||||
export async function changePassword(
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
): Promise<{ message: string }> {
|
||||
const payload: ChangePasswordRequest = {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
}
|
||||
|
||||
const { data } = await apiClient.put<{ message: string }>('/user/password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification code for adding a notify email
|
||||
* @param email - Email address to verify
|
||||
*/
|
||||
export async function sendNotifyEmailCode(email: string): Promise<void> {
|
||||
await apiClient.post('/user/notify-email/send-code', { email })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and add a notify email
|
||||
* @param email - Email address to add
|
||||
* @param code - Verification code
|
||||
*/
|
||||
export async function verifyNotifyEmail(email: string, code: string): Promise<void> {
|
||||
await apiClient.post('/user/notify-email/verify', { email, code })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notify email
|
||||
* @param email - Email address to remove
|
||||
*/
|
||||
export async function removeNotifyEmail(email: string): Promise<void> {
|
||||
await apiClient.delete('/user/notify-email', { data: { email } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a notify email's disabled state
|
||||
* @param email - Email address (empty string for primary email placeholder)
|
||||
* @param disabled - Whether to disable the email
|
||||
*/
|
||||
export async function toggleNotifyEmail(email: string, disabled: boolean): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user/notify-email/toggle', { email, disabled })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function sendEmailBindingCode(email: string): Promise<void> {
|
||||
await apiClient.post('/user/account-bindings/email/send-code', { email })
|
||||
}
|
||||
|
||||
export async function bindEmailIdentity(payload: {
|
||||
email: string
|
||||
verify_code: string
|
||||
password: string
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.post<User>('/user/account-bindings/email', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbindAuthIdentity(provider: BindableOAuthProvider): Promise<User> {
|
||||
const { data } = await apiClient.delete<User>(`/user/account-bindings/${provider}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export type BindableOAuthProvider = Exclude<UserAuthProvider, 'email'>
|
||||
|
||||
interface BuildOAuthBindingStartURLOptions {
|
||||
redirectTo?: string
|
||||
wechatOAuthSettings?: WeChatOAuthPublicSettings | null
|
||||
}
|
||||
|
||||
export function resolveWeChatOAuthMode(): 'open' | 'mp' {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'open'
|
||||
}
|
||||
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
|
||||
}
|
||||
|
||||
function resolveWeChatOAuthBindingMode(
|
||||
settings?: WeChatOAuthPublicSettings | null
|
||||
): 'open' | 'mp' | null {
|
||||
if (settings) {
|
||||
return resolveWeChatOAuthStartStrict(settings).mode
|
||||
}
|
||||
return resolveWeChatOAuthMode()
|
||||
}
|
||||
|
||||
export function buildOAuthBindingStartURL(
|
||||
provider: BindableOAuthProvider,
|
||||
options: BuildOAuthBindingStartURLOptions = {}
|
||||
): string | null {
|
||||
const redirectTo = options.redirectTo?.trim() || '/profile'
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const params = new URLSearchParams({
|
||||
redirect: redirectTo,
|
||||
intent: 'bind_current_user'
|
||||
})
|
||||
|
||||
if (provider === 'wechat') {
|
||||
const mode = resolveWeChatOAuthBindingMode(options.wechatOAuthSettings)
|
||||
if (!mode) {
|
||||
return null
|
||||
}
|
||||
params.set('mode', mode)
|
||||
}
|
||||
|
||||
return `${normalized}/auth/oauth/${provider}/bind/start?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function startOAuthBinding(
|
||||
provider: BindableOAuthProvider,
|
||||
options: BuildOAuthBindingStartURLOptions = {}
|
||||
): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const startURL = buildOAuthBindingStartURL(provider, options)
|
||||
if (!startURL) {
|
||||
return
|
||||
}
|
||||
await prepareOAuthBindAccessTokenCookie()
|
||||
window.location.href = startURL
|
||||
}
|
||||
|
||||
export async function getAffiliateDetail(): Promise<UserAffiliateDetail> {
|
||||
const { data } = await apiClient.get<UserAffiliateDetail>('/user/aff')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function transferAffiliateQuota(): Promise<AffiliateTransferResponse> {
|
||||
const { data } = await apiClient.post<AffiliateTransferResponse>('/user/aff/transfer')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的平台限额 + 用量。
|
||||
*/
|
||||
export async function getMyPlatformQuotas(): Promise<PlatformQuotasResponse> {
|
||||
const { data } = await apiClient.get<PlatformQuotasResponse>('/user/platform-quotas')
|
||||
return data
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
sendNotifyEmailCode,
|
||||
verifyNotifyEmail,
|
||||
removeNotifyEmail,
|
||||
toggleNotifyEmail,
|
||||
sendEmailBindingCode,
|
||||
bindEmailIdentity,
|
||||
unbindAuthIdentity,
|
||||
buildOAuthBindingStartURL,
|
||||
startOAuthBinding,
|
||||
getAffiliateDetail,
|
||||
transferAffiliateQuota,
|
||||
getMyPlatformQuotas,
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48.1 32.3" role="img" aria-label="Airwallex">
|
||||
<defs>
|
||||
<linearGradient id="airwallex-mark" x1="0" y1="2" x2="48" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FF4F42"/>
|
||||
<stop offset="1" stop-color="#FF8E3C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#airwallex-mark)"
|
||||
d="M312.76 380.53a6 6 0 0 1 1.42 6.42l-3.18 8.58a6.89 6.89 0 0 1-5 4.47 6.8 6.8 0 0 1-1.3.13 6.58 6.58 0 0 1-5.08-2.4l-19-22.69a.42.42 0 0 0-.71.12l-6.17 16.67a.42.42 0 0 0 .55.54l7.57-3.09a3.34 3.34 0 0 1 4.44 2.08 3.47 3.47 0 0 1-2 4.24l-9.89 4a5.93 5.93 0 0 1-7.88-7.56l7.29-19.68a6.84 6.84 0 0 1 11.68-2l10.88 13 10-4.08a5.84 5.84 0 0 1 6.38 1.24ZM307 387.07a.42.42 0 0 0-.55-.54l-5.53 2.26 3.32 4a.42.42 0 0 0 .71-.13Z"
|
||||
transform="translate(-266.13 -367.85)"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 858 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563099286" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1395" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M902.095 652.871l-250.96-84.392s19.287-28.87 39.874-85.472c20.59-56.606 23.539-87.689 23.539-87.689l-162.454-1.339v-55.487l196.739-1.387v-39.227H552.055v-89.29h-96.358v89.294H272.133v39.227l183.564-1.304v59.513h-147.24v31.079h303.064s-3.337 25.223-14.955 56.606c-11.615 31.38-23.58 58.862-23.58 58.862s-142.3-49.804-217.285-49.804c-74.985 0-166.182 30.123-175.024 117.55-8.8 87.383 42.481 134.716 114.728 152.139 72.256 17.513 138.962-0.173 197.04-28.607 58.087-28.391 115.081-92.933 115.081-92.933l292.486 142.041c-11.932 69.3-72.067 119.914-142.387 119.844H266.37c-79.714 0.078-144.392-64.483-144.466-144.194V266.374c-0.074-79.72 64.493-144.399 144.205-144.47h491.519c79.714-0.073 144.396 64.49 144.466 144.203v386.764z m-365.76-48.895s-91.302 115.262-198.879 115.262c-107.623 0-130.218-54.767-130.218-94.155 0-39.34 22.373-82.144 113.943-88.333 91.519-6.18 215.2 67.226 215.2 67.226h-0.047z" fill="#02A9F1" p-id="1396"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563141699" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2705" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M647.3728 287.744c-2.048-4.9152-5.7344-9.0112-11.0592-12.0832-5.3248-3.072-12.9024-4.7104-22.9376-4.7104h-221.184c-0.2048 0-0.2048 0.2048-0.2048 0.2048V305.152h260.096c-1.024-6.7584-2.6624-12.4928-4.7104-17.408zM634.0608 400.9984c6.9632-3.2768 11.264-8.192 13.1072-14.5408l5.12-14.7456h-260.096c-0.2048 0-0.2048 0.2048-0.2048 0.2048V405.504c0 0.2048 0.2048 0.2048 0.2048 0.2048h220.9792c6.9632 0.4096 13.9264-1.4336 20.8896-4.7104z" fill="#48D8FF" p-id="2706"></path><path d="M512 1.6384C230.1952 1.6384 1.6384 230.1952 1.6384 512S230.1952 1022.3616 512 1022.3616 1022.3616 793.8048 1022.3616 512 793.8048 1.6384 512 1.6384z m289.5872 644.3008c-0.2048 4.3008-1.2288 20.48-3.2768 48.5376s-4.9152 50.7904-8.8064 67.9936c-3.8912 17.408-13.5168 31.3344-29.0816 41.984-15.5648 10.6496-30.72 16.384-45.2608 17.408-12.9024 0.8192-25.1904-1.024-36.2496-5.9392-11.264-4.9152-20.48-10.8544-27.8528-18.2272-7.3728-7.3728-13.7216-16.5888-19.0464-28.0576-5.3248-11.4688-6.9632-18.0224-4.9152-20.0704 2.048-1.8432 4.096-3.2768 6.3488-3.8912 3.072-0.6144 8.3968-0.2048 15.9744 1.6384s13.9264 2.8672 19.2512 3.072c5.7344 0.4096 12.0832 0 18.8416-1.4336 6.7584-1.4336 12.0832-3.6864 16.384-6.9632 4.096-3.2768 7.3728-7.7824 9.8304-13.7216 2.2528-5.9392 3.8912-13.1072 4.7104-21.504 0.8192-8.3968 1.8432-22.3232 3.072-41.984s2.048-32.5632 2.2528-39.1168v-29.2864c0-6.7584-1.024-11.8784-3.2768-15.36-2.2528-3.4816-7.5776-5.12-15.7696-5.12h-2.4576c-22.3232 0-43.8272 8.6016-60.2112 23.9616-7.9872 7.5776-16.1792 17.408-23.9616 29.4912-7.9872 12.0832-15.5648 25.6-22.9376 40.7552l-18.2272 38.7072c-16.5888 33.9968-36.0448 61.2352-58.5728 81.3056-22.528 20.0704-65.1264 30.72-127.5904 31.9488-14.336 0.4096-24.9856-0.4096-32.1536-2.2528-6.9632-2.048-11.264-4.096-12.4928-6.144-1.2288-2.048-1.024-4.7104 0.4096-7.5776 1.024-2.2528 3.072-3.8912 5.9392-4.9152s11.4688-3.2768 25.8048-6.5536 30.5152-11.0592 48.3328-22.9376c17.8176-11.8784 27.2384-20.0704 35.4304-30.1056 8.192-10.0352 20.6848-26.4192 30.72-44.2368 10.0352-17.8176 24.1664-45.056 34.6112-60.6208 10.0352-15.1552 41.984-53.0432 81.5104-60.0064 0.4096 0 0.4096-0.6144 0-0.6144h-75.1616c-9.4208 0-18.8416 1.8432-27.2384 6.144-7.168 3.6864-10.8544 8.8064-27.0336 36.2496-15.9744 26.8288-36.864 51.2-36.864 51.2-20.2752 25.6-36.6592 43.6224-57.344 56.9344-20.6848 13.312-49.5616 19.0464-87.04 16.9984-12.288-0.4096-23.3472-1.8432-33.3824-4.096-9.8304-2.2528-15.1552-4.3008-15.7696-6.144-0.6144-1.8432-0.4096-3.8912 1.024-5.9392 0.8192-1.4336 4.9152-2.8672 12.288-4.7104 7.3728-1.8432 15.36-4.5056 24.1664-7.9872 8.8064-3.6864 40.3456-15.7696 72.9088-48.5376 28.2624-28.2624 50.3808-60.416 63.8976-73.1136 4.096-3.6864 16.384-13.7216 32.5632-17.408h-54.272c-11.4688 0-20.48 3.4816-29.4912 14.1312-15.9744 18.8416-31.1296 31.1296-46.08 36.0448-18.432 6.144-33.9968 9.4208-46.2848 9.8304h-30.5152c-16.7936 0.6144-25.6-0.6144-26.624-4.096-0.8192-3.2768-0.4096-6.144 1.4336-8.3968 1.8432-2.048 6.144-4.3008 12.6976-6.5536s14.336-6.3488 22.7328-12.0832c8.3968-5.7344 15.5648-11.4688 21.504-16.9984 5.9392-5.5296 12.288-12.9024 19.2512-22.1184l21.0944-29.4912c8.192-11.0592 19.456-22.3232 33.5872-33.9968l31.5392-25.6c0.2048-0.2048 0-0.6144-0.2048-0.6144h-69.0176c-0.2048 0-0.2048-0.2048-0.2048-0.2048V201.9328c0-0.2048 0.2048-0.2048 0.2048-0.2048h288.1536c66.3552 0 103.8336 16.9984 112.2304 50.9952s12.6976 63.0784 12.4928 87.6544c-0.2048 23.7568-2.6624 44.032-7.5776 61.0304-4.9152 16.9984-16.5888 33.5872-35.2256 50.176-18.6368 16.5888-46.08 24.7808-81.92 24.7808h-110.7968c-9.8304 0-19.456 2.8672-27.648 7.9872l-25.1904 15.7696c-3.2768 2.2528-6.144 4.5056-8.3968 6.9632h268.9024c22.9376 0 41.1648 1.2288 54.8864 3.4816 13.7216 2.2528 23.9616 7.7824 30.9248 16.384 6.9632 8.6016 11.0592 18.432 12.288 29.4912 1.2288 11.0592 1.8432 24.3712 1.8432 39.936-0.4096 28.672-0.6144 45.056-0.6144 49.5616z" fill="#48D8FF" p-id="2707"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Payment">
|
||||
<path d="M512 64c247.424 0 448 200.576 448 448S759.424 960 512 960 64 759.424 64 512 264.576 64 512 64Z" fill="#4F46E5"/>
|
||||
<path d="M307 329c0-39.765 32.235-72 72-72h274c39.765 0 72 32.235 72 72v36H371c-35.346 0-64 28.654-64 64V329Z" fill="#C7D2FE"/>
|
||||
<path d="M260 413c0-35.346 28.654-64 64-64h392c35.346 0 64 28.654 64 64v258c0 35.346-28.654 64-64 64H324c-35.346 0-64-28.654-64-64V413Z" fill="#FFFFFF"/>
|
||||
<path d="M636 481c0-30.928 25.072-56 56-56h88v214h-88c-30.928 0-56-25.072-56-56V481Z" fill="#EEF2FF"/>
|
||||
<path d="M708 494c30.928 0 56 25.072 56 56s-25.072 56-56 56-56-25.072-56-56 25.072-56 56-56Z" fill="#FBBF24"/>
|
||||
<path d="M348 457c0-17.673 14.327-32 32-32h172c17.673 0 32 14.327 32 32s-14.327 32-32 32H380c-17.673 0-32-14.327-32-32Z" fill="#4F46E5"/>
|
||||
<path d="M348 557c0-15.464 12.536-28 28-28h152c15.464 0 28 12.536 28 28s-12.536 28-28 28H376c-15.464 0-28-12.536-28-28Z" fill="#A5B4FC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563184449" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3692" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 512m-448 0a448 448 0 1 0 896 0 448 448 0 1 0-896 0Z" fill="#676BE5" p-id="3693"></path><path d="M471.616 417.296c0-20.96 17.488-29.04 45.488-29.04a300.8 300.8 0 0 1 133.36 34.496v-126.224a353.6 353.6 0 0 0-133.264-24.528c-108.8 0-181.2 56.768-181.2 151.696 0 148.4 203.76 124.336 203.76 188.352 0 24.816-21.52 32.8-51.408 32.8a338.368 338.368 0 0 1-146.704-42.768v120.768a372.272 372.272 0 0 0 146.624 30.4c111.472 0 188.256-48 188.256-144.368 0-160-204.88-131.296-204.88-191.632" fill="#FFFFFF" p-id="3694"></path></svg>
|
||||
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563104166" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1545" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M395.846 603.585c-3.921 1.98-7.936 2.925-12.81 2.925-10.9 0-19.791-5.85-24.764-14.625l-2.006-3.864-78.106-167.913c-0.956-1.98-0.956-3.865-0.956-5.845 0-7.83 5.928-13.68 13.863-13.68 2.965 0 5.928 0.944 8.893 2.924l91.965 64.43c6.884 3.864 14.82 6.79 23.708 6.79 4.972 0 9.85-0.945 14.822-2.926L861.71 282.479c-77.149-89.804-204.684-148.384-349.135-148.384-235.371 0-427.242 157.158-427.242 351.294 0 105.368 57.361 201.017 147.323 265.447 6.88 4.905 11.852 13.68 11.852 22.45 0 2.925-0.957 5.85-2.006 8.775-6.881 26.318-18.831 69.334-18.831 71.223-0.958 2.92-2.013 6.79-2.013 10.75 0 7.83 5.929 13.68 13.865 13.68 2.963 0 5.928-0.944 7.935-2.925l92.922-53.674c6.885-3.87 14.82-6.794 22.756-6.794 3.916 0 8.889 0.944 12.81 1.98 43.496 12.644 91.012 19.53 139.48 19.53 235.372 0 427.24-157.158 427.24-351.294 0-58.58-17.78-114.143-48.467-163.003l-491.39 280.07-2.963 1.98z" fill="#09BB07" p-id="1546"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div v-if="sceneId && prefix" class="aliyun-captcha-wrapper">
|
||||
<button
|
||||
:id="buttonId"
|
||||
type="button"
|
||||
class="aliyun-captcha-button"
|
||||
:class="state === 'verified' ? 'aliyun-captcha-button--verified' : ''"
|
||||
:disabled="state === 'verified'"
|
||||
>
|
||||
<svg
|
||||
v-if="state === 'verified'"
|
||||
class="aliyun-captcha-icon"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="aliyun-captcha-icon"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.661 2.237a.531.531 0 01.678 0 11.947 11.947 0 007.078 2.749.5.5 0 01.479.425c.069.52.104 1.05.104 1.59 0 5.162-3.26 9.563-7.834 11.256a.48.48 0 01-.332 0C5.26 16.564 2 12.163 2 7c0-.538.035-1.069.104-1.589a.5.5 0 01.48-.425 11.947 11.947 0 007.077-2.75z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ buttonText }}</span>
|
||||
</button>
|
||||
<div :id="elementId"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface AliyunCaptchaVerifyResult {
|
||||
captchaResult: boolean
|
||||
bizResult?: boolean
|
||||
}
|
||||
|
||||
interface AliyunCaptchaInitOptions {
|
||||
SceneId: string
|
||||
prefix: string
|
||||
mode: 'popup' | 'embed'
|
||||
element: string
|
||||
button: string
|
||||
captchaVerifyCallback: (
|
||||
captchaVerifyParam: string
|
||||
) => AliyunCaptchaVerifyResult | Promise<AliyunCaptchaVerifyResult>
|
||||
onBizResultCallback: (bizResult: boolean) => void
|
||||
getInstance: (instance: unknown) => void
|
||||
slideStyle?: { width: number; height: number }
|
||||
language?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initAliyunCaptcha?: (options: AliyunCaptchaInitOptions) => void
|
||||
AliyunCaptchaConfig?: { region: string; prefix: string }
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sceneId: string
|
||||
prefix: string
|
||||
region?: 'cn' | 'sgp'
|
||||
}>(),
|
||||
{
|
||||
region: 'cn'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'verify', param: string): void
|
||||
(e: 'expire'): void
|
||||
(e: 'error'): void
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const uid = Math.random().toString(36).slice(2, 10)
|
||||
const buttonId = `aliyun-captcha-button-${uid}`
|
||||
const elementId = `aliyun-captcha-element-${uid}`
|
||||
|
||||
// idle: 未验证可点击;verifying: 弹窗已拉起(关闭后回到 idle 可重试);verified: 已通过
|
||||
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
|
||||
|
||||
const buttonText = computed(() => {
|
||||
switch (state.value) {
|
||||
case 'verified':
|
||||
return t('auth.captchaVerified')
|
||||
case 'verifying':
|
||||
return t('auth.captchaVerifying')
|
||||
default:
|
||||
return t('auth.captchaClickToVerify')
|
||||
}
|
||||
})
|
||||
|
||||
const SCRIPT_SRC = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
|
||||
const POPUP_ID = 'aliyunCaptcha-window-popup'
|
||||
const MASK_ID = 'aliyunCaptcha-mask'
|
||||
const POPUP_OPEN_TIMEOUT_MS = 8000
|
||||
const POPUP_WATCH_INTERVAL_MS = 300
|
||||
|
||||
// captchaVerifyParam 是一次性参数:verified 后缓存于此,提交失败需 reset 后重新验证
|
||||
let cachedParam: string | null = null
|
||||
let pending: { resolve: (value: string | null) => void } | null = null
|
||||
let popupWatchTimer: number | null = null
|
||||
let readyPromise: Promise<void> | null = null
|
||||
|
||||
const loadScript = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 全局配置必须在脚本加载前就位(region/prefix 全站一致,重复赋值无副作用)
|
||||
window.AliyunCaptchaConfig = { region: props.region, prefix: props.prefix }
|
||||
|
||||
if (window.initAliyunCaptcha) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
'script[src*="aliyunCaptcha/AliyunCaptcha"]'
|
||||
)
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', () => resolve())
|
||||
existingScript.addEventListener('error', () =>
|
||||
reject(new Error('Failed to load Aliyun captcha script'))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = SCRIPT_SRC
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('Failed to load Aliyun captcha script'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
function initCaptcha(): void {
|
||||
if (!window.initAliyunCaptcha) {
|
||||
throw new Error('Aliyun captcha script not ready')
|
||||
}
|
||||
window.initAliyunCaptcha({
|
||||
SceneId: props.sceneId,
|
||||
prefix: props.prefix,
|
||||
mode: 'popup',
|
||||
element: `#${elementId}`,
|
||||
button: `#${buttonId}`,
|
||||
// 这里不发业务请求,只把 captchaVerifyParam 当 token 交给页面,随登录/注册等
|
||||
// 业务请求的 turnstile_token 字段提交,由后端在业务接口内调阿里云校验。
|
||||
captchaVerifyCallback: (captchaVerifyParam: string) => {
|
||||
onCaptchaParam(captchaVerifyParam)
|
||||
return { captchaResult: true }
|
||||
},
|
||||
onBizResultCallback: () => {},
|
||||
getInstance: () => {},
|
||||
slideStyle: { width: 360, height: 40 },
|
||||
language: locale.value.toLowerCase().startsWith('zh') ? 'cn' : 'en'
|
||||
})
|
||||
}
|
||||
|
||||
function ensureReady(): Promise<void> {
|
||||
if (!readyPromise) {
|
||||
readyPromise = loadScript().then(() => initCaptcha())
|
||||
readyPromise.catch(() => {
|
||||
// 失败后允许下次重试(如网络恢复)
|
||||
readyPromise = null
|
||||
})
|
||||
}
|
||||
return readyPromise
|
||||
}
|
||||
|
||||
function onCaptchaParam(param: string): void {
|
||||
stopPopupWatch()
|
||||
cachedParam = param
|
||||
state.value = 'verified'
|
||||
emit('verify', param)
|
||||
const current = pending
|
||||
pending = null
|
||||
current?.resolve(param)
|
||||
}
|
||||
|
||||
function settlePending(value: string | null): void {
|
||||
const current = pending
|
||||
pending = null
|
||||
current?.resolve(value)
|
||||
}
|
||||
|
||||
function isPopupVisible(): boolean {
|
||||
const popup = document.getElementById(POPUP_ID)
|
||||
if (!popup) return false
|
||||
return window.getComputedStyle(popup).display !== 'none'
|
||||
}
|
||||
|
||||
function stopPopupWatch(): void {
|
||||
if (popupWatchTimer !== null) {
|
||||
window.clearInterval(popupWatchTimer)
|
||||
popupWatchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// SDK 没有用户关闭弹窗的回调,靠轮询弹窗可见性兜底:
|
||||
// 弹窗出现过又消失且未产生 param → 用户主动关闭;迟迟未出现 → 打开失败。
|
||||
// initAliyunCaptcha 对触发按钮的事件绑定是异步完成的,首次 click 可能落空,
|
||||
// 因此弹窗出现前每个 tick 重试触发一次。
|
||||
function startPopupWatch(): void {
|
||||
stopPopupWatch()
|
||||
const startedAt = Date.now()
|
||||
let seen = false
|
||||
popupWatchTimer = window.setInterval(() => {
|
||||
if (state.value === 'verified') {
|
||||
stopPopupWatch()
|
||||
return
|
||||
}
|
||||
if (isPopupVisible()) {
|
||||
seen = true
|
||||
return
|
||||
}
|
||||
if (seen || Date.now() - startedAt > POPUP_OPEN_TIMEOUT_MS) {
|
||||
stopPopupWatch()
|
||||
state.value = 'idle'
|
||||
settlePending(null)
|
||||
return
|
||||
}
|
||||
document.getElementById(buttonId)?.click()
|
||||
}, POPUP_WATCH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// 用户点击与程序化触发共用:置 verifying 并启动弹窗监视(幂等,重试 click 不重置计时)
|
||||
function handleTriggerClick(): void {
|
||||
if (state.value === 'verified') {
|
||||
return
|
||||
}
|
||||
state.value = 'verifying'
|
||||
if (popupWatchTimer === null) {
|
||||
startPopupWatch()
|
||||
}
|
||||
}
|
||||
|
||||
// 程序化触发验证(OAuth 启动、passkey、未预验证时的表单提交兜底):
|
||||
// 已通过预验证则直接复用缓存的 captchaVerifyParam;否则弹出验证码等待结果。
|
||||
// 用户关闭/未能弹出 resolve null;脚本加载失败 reject。
|
||||
async function verify(): Promise<string | null> {
|
||||
if (state.value === 'verified' && cachedParam) {
|
||||
return cachedParam
|
||||
}
|
||||
settlePending(null)
|
||||
await ensureReady()
|
||||
return new Promise<string | null>((resolve) => {
|
||||
pending = { resolve }
|
||||
document.getElementById(buttonId)?.click()
|
||||
})
|
||||
}
|
||||
|
||||
// 重置为未验证态;captchaVerifyParam 是一次性参数,服务端校验失败后需用户重新验证
|
||||
function reset(): void {
|
||||
stopPopupWatch()
|
||||
settlePending(null)
|
||||
cachedParam = null
|
||||
state.value = 'idle'
|
||||
}
|
||||
|
||||
defineExpose({ verify, reset })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.sceneId || !props.prefix) {
|
||||
return
|
||||
}
|
||||
|
||||
document.getElementById(buttonId)?.addEventListener('click', handleTriggerClick)
|
||||
try {
|
||||
await ensureReady()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Aliyun captcha:', error)
|
||||
emit('error')
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.getElementById(buttonId)?.removeEventListener('click', handleTriggerClick)
|
||||
stopPopupWatch()
|
||||
settlePending(null)
|
||||
// SDK 不会自清理弹窗 DOM,残留会导致下次挂载时回调重复触发
|
||||
document.getElementById(MASK_ID)?.remove()
|
||||
document.getElementById(POPUP_ID)?.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aliyun-captcha-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aliyun-captcha-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgb(209 213 219);
|
||||
background-color: rgb(249 250 251);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(55 65 81);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.aliyun-captcha-button:hover:not(:disabled) {
|
||||
border-color: rgb(156 163 175);
|
||||
background-color: rgb(243 244 246);
|
||||
}
|
||||
|
||||
.aliyun-captcha-button--verified {
|
||||
border-color: rgb(34 197 94);
|
||||
background-color: rgb(240 253 244);
|
||||
color: rgb(21 128 61);
|
||||
}
|
||||
|
||||
:root.dark .aliyun-captcha-button,
|
||||
.dark .aliyun-captcha-button {
|
||||
border-color: rgb(55 65 81);
|
||||
background-color: rgb(31 41 55);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
|
||||
:root.dark .aliyun-captcha-button:hover:not(:disabled),
|
||||
.dark .aliyun-captcha-button:hover:not(:disabled) {
|
||||
border-color: rgb(75 85 99);
|
||||
background-color: rgb(55 65 81);
|
||||
}
|
||||
|
||||
:root.dark .aliyun-captcha-button--verified,
|
||||
.dark .aliyun-captcha-button--verified {
|
||||
border-color: rgb(34 197 94);
|
||||
background-color: rgb(20 83 45 / 0.3);
|
||||
color: rgb(134 239 172);
|
||||
}
|
||||
|
||||
.aliyun-captcha-icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<TurnstileWidget
|
||||
v-if="turnstileEnabled && turnstileSiteKey"
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="(token) => emit('verify', token, '')"
|
||||
@expire="emit('expire')"
|
||||
@error="emit('error')"
|
||||
/>
|
||||
<TencentCaptchaGate
|
||||
v-else-if="tencentEnabled && tencentAppId"
|
||||
ref="tencentRef"
|
||||
:app-id="tencentAppId"
|
||||
:region="tencentRegion"
|
||||
/>
|
||||
<AliyunCaptchaWidget
|
||||
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
|
||||
ref="aliyunRef"
|
||||
:scene-id="aliyunSceneId"
|
||||
:prefix="aliyunPrefix"
|
||||
:region="aliyunRegion === 'sgp' ? 'sgp' : 'cn'"
|
||||
@verify="(param: string) => emit('verify', param, '')"
|
||||
@expire="emit('expire')"
|
||||
@error="emit('error')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
|
||||
import AliyunCaptchaWidget from '@/components/AliyunCaptchaWidget.vue'
|
||||
|
||||
// ActionCaptchaResult 动作触发式验证(腾讯/阿里云弹窗)的结果:
|
||||
// 腾讯 token=ticket、randstr 非空;阿里云 token=captchaVerifyParam、randstr 恒为空。
|
||||
export interface ActionCaptchaResult {
|
||||
token: string
|
||||
randstr: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
siteKey?: string
|
||||
turnstileEnabled: boolean
|
||||
turnstileSiteKey: string
|
||||
tencentEnabled: boolean
|
||||
tencentAppId: string
|
||||
tencentRegion?: string
|
||||
aliyunEnabled?: boolean
|
||||
aliyunSceneId?: string
|
||||
aliyunPrefix?: string
|
||||
aliyunRegion?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verify: [tokenOrTicket: string, randstr: string]
|
||||
expire: []
|
||||
error: []
|
||||
}>()
|
||||
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
|
||||
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
|
||||
|
||||
function reset(): void {
|
||||
turnstileRef.value?.reset()
|
||||
tencentRef.value?.reset()
|
||||
aliyunRef.value?.reset()
|
||||
}
|
||||
|
||||
// verifyAction 弹出当前启用的动作触发式验证码(腾讯/阿里云)并等待结果;
|
||||
// 用户关闭弹窗返回 null,验证异常 emit('error') 并返回 null。
|
||||
async function verifyAction(): Promise<ActionCaptchaResult | null> {
|
||||
if (props.tencentEnabled && props.tencentAppId) {
|
||||
try {
|
||||
const proof = (await tencentRef.value?.verify()) ?? null
|
||||
if (!proof) return null
|
||||
return { token: proof.ticket, randstr: proof.randstr }
|
||||
} catch {
|
||||
emit('error')
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (props.aliyunEnabled && props.aliyunSceneId && props.aliyunPrefix) {
|
||||
try {
|
||||
const param = (await aliyunRef.value?.verify()) ?? null
|
||||
if (!param) return null
|
||||
return { token: param, randstr: '' }
|
||||
} catch {
|
||||
emit('error')
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
defineExpose({ reset, verifyAction })
|
||||
</script>
|
||||
@@ -0,0 +1,309 @@
|
||||
import { DriveStep } from 'driver.js'
|
||||
|
||||
/**
|
||||
* 管理员完整引导流程
|
||||
* 交互式引导:指引用户实际操作
|
||||
* @param t 国际化函数
|
||||
* @param isSimpleMode 是否为简易模式(简易模式下会过滤分组相关步骤)
|
||||
*/
|
||||
export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false): DriveStep[] => {
|
||||
const allSteps: DriveStep[] = [
|
||||
// ========== 欢迎介绍 ==========
|
||||
{
|
||||
popover: {
|
||||
title: t('onboarding.admin.welcome.title'),
|
||||
description: t('onboarding.admin.welcome.description'),
|
||||
align: 'center',
|
||||
nextBtnText: t('onboarding.admin.welcome.nextBtn'),
|
||||
prevBtnText: t('onboarding.admin.welcome.prevBtn')
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 第一部分:创建分组 ==========
|
||||
{
|
||||
element: '#sidebar-group-manage',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupManage.title'),
|
||||
description: t('onboarding.admin.groupManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close'],
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="groups-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.createGroup.title'),
|
||||
description: t('onboarding.admin.createGroup.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupName.title'),
|
||||
description: t('onboarding.admin.groupName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-platform"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupPlatform.title'),
|
||||
description: t('onboarding.admin.groupPlatform.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-multiplier"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupMultiplier.title'),
|
||||
description: t('onboarding.admin.groupMultiplier.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-exclusive"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupExclusive.title'),
|
||||
description: t('onboarding.admin.groupExclusive.description'),
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupSubmit.title'),
|
||||
description: t('onboarding.admin.groupSubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 第二部分:创建账号授权 ==========
|
||||
{
|
||||
element: '#sidebar-channel-manage',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountManage.title'),
|
||||
description: t('onboarding.admin.accountManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="accounts-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.createAccount.title'),
|
||||
description: t('onboarding.admin.createAccount.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountName.title'),
|
||||
description: t('onboarding.admin.accountName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-platform"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountPlatform.title'),
|
||||
description: t('onboarding.admin.accountPlatform.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-type"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountType.title'),
|
||||
description: t('onboarding.admin.accountType.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-priority"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountPriority.title'),
|
||||
description: t('onboarding.admin.accountPriority.description'),
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-groups"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountGroups.title'),
|
||||
description: t('onboarding.admin.accountGroups.description'),
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountSubmit.title'),
|
||||
description: t('onboarding.admin.accountSubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 第三部分:创建API密钥 ==========
|
||||
{
|
||||
element: '[data-tour="sidebar-my-keys"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keyManage.title'),
|
||||
description: t('onboarding.admin.keyManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="keys-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.createKey.title'),
|
||||
description: t('onboarding.admin.createKey.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keyName.title'),
|
||||
description: t('onboarding.admin.keyName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-group"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keyGroup.title'),
|
||||
description: t('onboarding.admin.keyGroup.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keySubmit.title'),
|
||||
description: t('onboarding.admin.keySubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 简易模式下过滤分组相关步骤
|
||||
if (isSimpleMode) {
|
||||
return allSteps.filter(step => {
|
||||
const element = step.element as string | undefined
|
||||
// 过滤掉分组管理和账号分组选择相关步骤
|
||||
return !element || (
|
||||
!element.includes('sidebar-group-manage') &&
|
||||
!element.includes('groups-create-btn') &&
|
||||
!element.includes('group-form-') &&
|
||||
!element.includes('account-form-groups')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return allSteps
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通用户引导流程
|
||||
*/
|
||||
export const getUserSteps = (t: (key: string) => string): DriveStep[] => [
|
||||
{
|
||||
popover: {
|
||||
title: t('onboarding.user.welcome.title'),
|
||||
description: t('onboarding.user.welcome.description'),
|
||||
align: 'center',
|
||||
nextBtnText: t('onboarding.user.welcome.nextBtn'),
|
||||
prevBtnText: t('onboarding.user.welcome.prevBtn')
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="sidebar-my-keys"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keyManage.title'),
|
||||
description: t('onboarding.user.keyManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="keys-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.createKey.title'),
|
||||
description: t('onboarding.user.createKey.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keyName.title'),
|
||||
description: t('onboarding.user.keyName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-group"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keyGroup.title'),
|
||||
description: t('onboarding.user.keyGroup.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['next', 'previous']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keySubmit.title'),
|
||||
description: t('onboarding.user.keySubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isInternational"
|
||||
ref="internationalContainerRef"
|
||||
data-testid="tencent-captcha-international-container"
|
||||
:class="
|
||||
internationalContainerVisible
|
||||
? 'flex min-h-[60px] w-full justify-center'
|
||||
: 'pointer-events-none fixed left-1/2 top-0 flex h-[60px] w-[302px] -translate-x-1/2 scale-[0.01] opacity-0'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
loadTencentCaptcha,
|
||||
normalizeTencentCaptchaRegion,
|
||||
type TencentCaptchaProof,
|
||||
type TencentCaptchaResult
|
||||
} from '@/utils/tencentCaptcha'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
|
||||
const isInternational = computed(
|
||||
() => normalizeTencentCaptchaRegion(props.region) === 'intl'
|
||||
)
|
||||
const internationalContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const internationalContainerVisible = ref<boolean>(isInternational.value)
|
||||
|
||||
let instance: { show(): void; destroy(): void } | null = null
|
||||
let pending: Promise<TencentCaptchaProof | null> | null = null
|
||||
let cancelPending: (() => void) | null = null
|
||||
let cachedProof: TencentCaptchaProof | null = null
|
||||
let cachedProofCreatedAt = 0
|
||||
let isMounted = false
|
||||
|
||||
// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。
|
||||
const cachedProofMaxAgeMs = 4 * 60 * 1000
|
||||
|
||||
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (callback: () => void, keepInternationalContainer: boolean = false): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (cancelPending === cancel) cancelPending = null
|
||||
if (!keepInternationalContainer) {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
internationalContainerVisible.value = false
|
||||
}
|
||||
callback()
|
||||
}
|
||||
const cancel = (): void => finish(() => resolve(null))
|
||||
|
||||
cancelPending = cancel
|
||||
const region = normalizeTencentCaptchaRegion(props.region)
|
||||
if (region === 'intl' && revealInternational) {
|
||||
internationalContainerVisible.value = true
|
||||
}
|
||||
|
||||
void nextTick()
|
||||
.then(() => loadTencentCaptcha(region))
|
||||
.then((TencentCaptcha) => {
|
||||
if (cancelPending !== cancel) return
|
||||
|
||||
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
|
||||
const handleResult = (result: TencentCaptchaResult): void => {
|
||||
if (result.ret === 2) {
|
||||
finish(() => resolve(null))
|
||||
return
|
||||
}
|
||||
|
||||
const ticket = result.ticket?.trim() || ''
|
||||
const randstr = result.randstr?.trim() || ''
|
||||
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
|
||||
finish(() => reject(new Error('Tencent Captcha verification failed')))
|
||||
return
|
||||
}
|
||||
|
||||
// 国际站保留已勾选的控件,避免成功回调后页面出现跳动;登录流程结束时由 reset 清理。
|
||||
finish(() => resolve({ ticket, randstr }), region === 'intl')
|
||||
}
|
||||
|
||||
if (region === 'intl') {
|
||||
// 新版国际站先在指定容器内展示 Robot checkbox,用户点击后才弹出挑战。
|
||||
// document.body 会把 checkbox 追加到整个页面末尾,在登录页上通常落到视口外,
|
||||
// 表现为 SDK 已成功初始化但页面没有任何可见组件。
|
||||
const container = internationalContainerRef.value
|
||||
if (!container) {
|
||||
throw new Error('Tencent Captcha international container is unavailable')
|
||||
}
|
||||
instance = new TencentCaptcha(container, props.appId, handleResult, {
|
||||
// 国际站与国内站保持一致:页面加载后显示 Robot checkbox,由用户主动确认。
|
||||
enableAutoCheck: false,
|
||||
userLanguage,
|
||||
type: 'popup'
|
||||
})
|
||||
} else {
|
||||
instance = new TencentCaptcha(props.appId, handleResult, { userLanguage })
|
||||
}
|
||||
instance.show()
|
||||
})
|
||||
.catch((error: unknown) => finish(() => reject(error)))
|
||||
})
|
||||
}
|
||||
|
||||
function verify(): Promise<TencentCaptchaProof | null> {
|
||||
if (cachedProof) {
|
||||
if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) {
|
||||
// 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。
|
||||
reset(false)
|
||||
} else {
|
||||
const proof = cachedProof
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
// 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。
|
||||
pending = null
|
||||
return Promise.resolve(proof)
|
||||
}
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
const verification = pending
|
||||
internationalContainerVisible.value = true
|
||||
// 预加载阶段可能因容器不可见而被 SDK 延迟绘制,提交时在容器显示后重新触发同一个实例。
|
||||
void nextTick().then(() => {
|
||||
if (pending === verification) instance?.show()
|
||||
})
|
||||
return verification.then((proof) => {
|
||||
if (cachedProof === proof) {
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
}
|
||||
return proof
|
||||
})
|
||||
}
|
||||
|
||||
internationalContainerVisible.value = true
|
||||
const verification = createVerificationPromise(true)
|
||||
pending = verification
|
||||
void verification.then(
|
||||
() => {
|
||||
if (pending === verification) pending = null
|
||||
},
|
||||
() => {
|
||||
if (pending === verification) pending = null
|
||||
}
|
||||
)
|
||||
return verification
|
||||
}
|
||||
|
||||
function preload(): void {
|
||||
if (!isInternational.value || pending || cachedProof) return
|
||||
|
||||
// 国际站要求首屏直接展示 checkbox,避免用户点击登录后才看到验证码。
|
||||
const verification = createVerificationPromise(true)
|
||||
pending = verification
|
||||
void verification
|
||||
.then((proof) => {
|
||||
if (proof) {
|
||||
cachedProof = proof
|
||||
cachedProofCreatedAt = Date.now()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (pending === verification) pending = null
|
||||
})
|
||||
}
|
||||
|
||||
function reset(reinitialize: boolean = true): void {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
cancelPending?.()
|
||||
cancelPending = null
|
||||
pending = null
|
||||
cachedProof = null
|
||||
cachedProofCreatedAt = 0
|
||||
internationalContainerVisible.value = isInternational.value
|
||||
|
||||
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
|
||||
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
|
||||
if (reinitialize && isMounted && isInternational.value) {
|
||||
void nextTick().then(() => {
|
||||
if (isMounted) preload()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isMounted = true
|
||||
preload()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
isMounted = false
|
||||
reset()
|
||||
})
|
||||
defineExpose({ verify, reset })
|
||||
</script>
|
||||