Merge pull request #603 from mt21625457/release
feat : 大幅度的性能优化 和 新增了很多功能
This commit is contained in:
@@ -39,16 +39,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => appStore.siteName,
|
||||
(newName) => {
|
||||
if (newName) {
|
||||
document.title = `${newName} - AI API Gateway`
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for authentication state and manage subscription data
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
|
||||
@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve('invalid json')
|
||||
})
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
|
||||
})
|
||||
|
||||
@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => {
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
208
frontend/src/api/__tests__/client.spec.ts
Normal file
208
frontend/src/api/__tests__/client.spec.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
|
||||
// 需要在导入 client 之前设置 mock
|
||||
vi.mock('@/i18n', () => ({
|
||||
getLocale: () => 'zh-CN',
|
||||
}))
|
||||
|
||||
describe('API Client', () => {
|
||||
let apiClient: AxiosInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear()
|
||||
// 每次测试重新导入以获取干净的模块状态
|
||||
vi.resetModules()
|
||||
const mod = await import('@/api/client')
|
||||
apiClient = mod.apiClient
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// --- 请求拦截器 ---
|
||||
|
||||
describe('请求拦截器', () => {
|
||||
it('自动附加 Authorization 头', async () => {
|
||||
localStorage.setItem('auth_token', 'my-jwt-token')
|
||||
|
||||
// 拦截实际请求
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBe('Bearer my-jwt-token')
|
||||
})
|
||||
|
||||
it('无 token 时不附加 Authorization 头', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('GET 请求自动附加 timezone 参数', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/test')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.params).toHaveProperty('timezone')
|
||||
})
|
||||
|
||||
it('POST 请求不附加 timezone 参数', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.post('/test', { foo: 'bar' })
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.params?.timezone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// --- 响应拦截器 ---
|
||||
|
||||
describe('响应拦截器', () => {
|
||||
it('code=0 时解包 data 字段', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: { name: 'test' }, message: 'ok' },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
const response = await apiClient.get('/test')
|
||||
expect(response.data).toEqual({ name: 'test' })
|
||||
})
|
||||
|
||||
it('code!=0 时拒绝并返回结构化错误', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 1001, message: '参数错误', data: null },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
code: 1001,
|
||||
message: '参数错误',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 401 Token 刷新 ---
|
||||
|
||||
describe('401 Token 刷新', () => {
|
||||
it('无 refresh_token 时 401 清除 localStorage', async () => {
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
// 不设置 refresh_token
|
||||
|
||||
// Mock window.location
|
||||
const originalLocation = window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...originalLocation, pathname: '/dashboard', href: '/dashboard' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
|
||||
},
|
||||
config: {
|
||||
url: '/test',
|
||||
headers: { Authorization: 'Bearer expired-token' },
|
||||
},
|
||||
code: 'ERR_BAD_REQUEST',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toBeDefined()
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
|
||||
// 恢复 location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- 网络错误 ---
|
||||
|
||||
describe('网络错误', () => {
|
||||
it('网络错误返回 status 0 的错误', async () => {
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
code: 'ERR_NETWORK',
|
||||
message: 'Network Error',
|
||||
config: { url: '/test' },
|
||||
// 没有 response
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 0,
|
||||
message: 'Network error. Please check your connection.',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 请求取消 ---
|
||||
|
||||
describe('请求取消', () => {
|
||||
it('取消的请求保持原始取消错误', async () => {
|
||||
const source = axios.CancelToken.source()
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue(
|
||||
new axios.Cancel('Operation canceled')
|
||||
)
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(
|
||||
apiClient.get('/test', { cancelToken: source.token })
|
||||
).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,58 @@ export async function list(
|
||||
return data
|
||||
}
|
||||
|
||||
export interface AccountListWithEtagResult {
|
||||
notModified: boolean
|
||||
etag: string | null
|
||||
data: PaginatedResponse<Account> | null
|
||||
}
|
||||
|
||||
export async function listWithEtag(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
etag?: string | null
|
||||
}
|
||||
): Promise<AccountListWithEtagResult> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (options?.etag) {
|
||||
headers['If-None-Match'] = options.etag
|
||||
}
|
||||
|
||||
const response = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
},
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
|
||||
})
|
||||
|
||||
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
|
||||
if (response.status === 304) {
|
||||
return {
|
||||
notModified: true,
|
||||
etag: etagHeader,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notModified: false,
|
||||
etag: etagHeader,
|
||||
data: response.data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account by ID
|
||||
* @param id - Account ID
|
||||
@@ -165,10 +217,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> {
|
||||
/**
|
||||
* Clear account rate limit status
|
||||
* @param id - Account ID
|
||||
* @returns Success confirmation
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function clearRateLimit(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>(
|
||||
export async function clearRateLimit(id: number): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>(
|
||||
`/admin/accounts/${id}/clear-rate-limit`
|
||||
)
|
||||
return data
|
||||
@@ -220,7 +272,7 @@ export async function generateAuthUrl(
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
endpoint: string,
|
||||
exchangeData: { session_id: string; code: string; proxy_id?: number }
|
||||
exchangeData: { session_id: string; code: string; state?: string; proxy_id?: number }
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData)
|
||||
return data
|
||||
@@ -442,7 +494,8 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
|
||||
*/
|
||||
export async function refreshOpenAIToken(
|
||||
refreshToken: string,
|
||||
proxyId?: number | null
|
||||
proxyId?: number | null,
|
||||
endpoint: string = '/admin/openai/refresh-token'
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: { refresh_token: string; proxy_id?: number } = {
|
||||
refresh_token: refreshToken
|
||||
@@ -450,12 +503,35 @@ export async function refreshOpenAIToken(
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
const { data } = await apiClient.post<Record<string, unknown>>('/admin/openai/refresh-token', payload)
|
||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Sora session token and exchange to access token
|
||||
* @param sessionToken - Sora session token
|
||||
* @param proxyId - Optional proxy ID
|
||||
* @param endpoint - API endpoint path
|
||||
* @returns Token information including access_token
|
||||
*/
|
||||
export async function validateSoraSessionToken(
|
||||
sessionToken: string,
|
||||
proxyId?: number | null,
|
||||
endpoint: string = '/admin/sora/st2at'
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: { session_token: string; proxy_id?: number } = {
|
||||
session_token: sessionToken
|
||||
}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
@@ -475,6 +551,7 @@ export const accountsAPI = {
|
||||
generateAuthUrl,
|
||||
exchangeCode,
|
||||
refreshOpenAIToken,
|
||||
validateSoraSessionToken,
|
||||
batchCreate,
|
||||
batchUpdateCredentials,
|
||||
bulkUpdate,
|
||||
|
||||
@@ -259,6 +259,40 @@ export interface OpsErrorDistributionResponse {
|
||||
items: OpsErrorDistributionItem[]
|
||||
}
|
||||
|
||||
export type OpsOpenAITokenStatsTimeRange = '30m' | '1h' | '1d' | '15d' | '30d'
|
||||
|
||||
export interface OpsOpenAITokenStatsItem {
|
||||
model: string
|
||||
request_count: number
|
||||
avg_tokens_per_sec?: number | null
|
||||
avg_first_token_ms?: number | null
|
||||
total_output_tokens: number
|
||||
avg_duration_ms: number
|
||||
requests_with_first_token: number
|
||||
}
|
||||
|
||||
export interface OpsOpenAITokenStatsResponse {
|
||||
time_range: OpsOpenAITokenStatsTimeRange
|
||||
start_time: string
|
||||
end_time: string
|
||||
platform?: string
|
||||
group_id?: number | null
|
||||
items: OpsOpenAITokenStatsItem[]
|
||||
total: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
top_n?: number | null
|
||||
}
|
||||
|
||||
export interface OpsOpenAITokenStatsParams {
|
||||
time_range?: OpsOpenAITokenStatsTimeRange
|
||||
platform?: string
|
||||
group_id?: number | null
|
||||
page?: number
|
||||
page_size?: number
|
||||
top_n?: number
|
||||
}
|
||||
|
||||
export interface OpsSystemMetricsSnapshot {
|
||||
id: number
|
||||
created_at: string
|
||||
@@ -816,6 +850,77 @@ export interface OpsAggregationSettings {
|
||||
aggregation_enabled: boolean
|
||||
}
|
||||
|
||||
export interface OpsRuntimeLogConfig {
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
enable_sampling: boolean
|
||||
sampling_initial: number
|
||||
sampling_thereafter: number
|
||||
caller: boolean
|
||||
stacktrace_level: 'none' | 'error' | 'fatal'
|
||||
retention_days: number
|
||||
source?: string
|
||||
updated_at?: string
|
||||
updated_by_user_id?: number
|
||||
}
|
||||
|
||||
export interface OpsSystemLog {
|
||||
id: number
|
||||
created_at: string
|
||||
level: string
|
||||
component: string
|
||||
message: string
|
||||
request_id?: string
|
||||
client_request_id?: string
|
||||
user_id?: number | null
|
||||
account_id?: number | null
|
||||
platform?: string
|
||||
model?: string
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
export type OpsSystemLogListResponse = PaginatedResponse<OpsSystemLog>
|
||||
|
||||
export interface OpsSystemLogQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
time_range?: '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d'
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
level?: string
|
||||
component?: string
|
||||
request_id?: string
|
||||
client_request_id?: string
|
||||
user_id?: number | null
|
||||
account_id?: number | null
|
||||
platform?: string
|
||||
model?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
export interface OpsSystemLogCleanupRequest {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
level?: string
|
||||
component?: string
|
||||
request_id?: string
|
||||
client_request_id?: string
|
||||
user_id?: number | null
|
||||
account_id?: number | null
|
||||
platform?: string
|
||||
model?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
export interface OpsSystemLogSinkHealth {
|
||||
queue_depth: number
|
||||
queue_capacity: number
|
||||
dropped_count: number
|
||||
write_failed_count: number
|
||||
written_count: number
|
||||
avg_write_delay_ms: number
|
||||
last_error?: string
|
||||
}
|
||||
|
||||
export interface OpsErrorLog {
|
||||
id: number
|
||||
created_at: string
|
||||
@@ -971,6 +1076,17 @@ export async function getErrorDistribution(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getOpenAITokenStats(
|
||||
params: OpsOpenAITokenStatsParams,
|
||||
options: OpsRequestOptions = {}
|
||||
): Promise<OpsOpenAITokenStatsResponse> {
|
||||
const { data } = await apiClient.get<OpsOpenAITokenStatsResponse>('/admin/ops/dashboard/openai-token-stats', {
|
||||
params,
|
||||
signal: options.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export type OpsErrorListView = 'errors' | 'excluded' | 'all'
|
||||
|
||||
export type OpsErrorListQueryParams = {
|
||||
@@ -1160,6 +1276,36 @@ export async function updateAlertRuntimeSettings(config: OpsAlertRuntimeSettings
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
|
||||
const { data } = await apiClient.get<OpsRuntimeLogConfig>('/admin/ops/runtime/logging')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateRuntimeLogConfig(config: OpsRuntimeLogConfig): Promise<OpsRuntimeLogConfig> {
|
||||
const { data } = await apiClient.put<OpsRuntimeLogConfig>('/admin/ops/runtime/logging', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
|
||||
const { data } = await apiClient.post<OpsRuntimeLogConfig>('/admin/ops/runtime/logging/reset')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listSystemLogs(params: OpsSystemLogQuery): Promise<OpsSystemLogListResponse> {
|
||||
const { data } = await apiClient.get<OpsSystemLogListResponse>('/admin/ops/system-logs', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function cleanupSystemLogs(payload: OpsSystemLogCleanupRequest): Promise<{ deleted: number }> {
|
||||
const { data } = await apiClient.post<{ deleted: number }>('/admin/ops/system-logs/cleanup', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSystemLogSinkHealth(): Promise<OpsSystemLogSinkHealth> {
|
||||
const { data } = await apiClient.get<OpsSystemLogSinkHealth>('/admin/ops/system-logs/health')
|
||||
return data
|
||||
}
|
||||
|
||||
// Advanced settings (DB-backed)
|
||||
export async function getAdvancedSettings(): Promise<OpsAdvancedSettings> {
|
||||
const { data } = await apiClient.get<OpsAdvancedSettings>('/admin/ops/advanced-settings')
|
||||
@@ -1188,6 +1334,7 @@ export const opsAPI = {
|
||||
getLatencyHistogram,
|
||||
getErrorTrend,
|
||||
getErrorDistribution,
|
||||
getOpenAITokenStats,
|
||||
getConcurrencyStats,
|
||||
getUserConcurrencyStats,
|
||||
getAccountAvailabilityStats,
|
||||
@@ -1226,10 +1373,16 @@ export const opsAPI = {
|
||||
updateEmailNotificationConfig,
|
||||
getAlertRuntimeSettings,
|
||||
updateAlertRuntimeSettings,
|
||||
getRuntimeLogConfig,
|
||||
updateRuntimeLogConfig,
|
||||
resetRuntimeLogConfig,
|
||||
getAdvancedSettings,
|
||||
updateAdvancedSettings,
|
||||
getMetricThresholds,
|
||||
updateMetricThresholds
|
||||
updateMetricThresholds,
|
||||
listSystemLogs,
|
||||
cleanupSystemLogs,
|
||||
getSystemLogSinkHealth
|
||||
}
|
||||
|
||||
export default opsAPI
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiClient } from '../client'
|
||||
import type {
|
||||
Proxy,
|
||||
ProxyAccountSummary,
|
||||
ProxyQualityCheckResult,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse,
|
||||
@@ -143,6 +144,16 @@ export async function testProxy(id: number): Promise<{
|
||||
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
|
||||
@@ -248,6 +259,7 @@ export const proxiesAPI = {
|
||||
delete: deleteProxy,
|
||||
toggleStatus,
|
||||
testProxy,
|
||||
checkProxyQuality,
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate,
|
||||
|
||||
184
frontend/src/components/__tests__/ApiKeyCreate.spec.ts
Normal file
184
frontend/src/components/__tests__/ApiKeyCreate.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* API Key 创建逻辑测试
|
||||
* 通过封装组件测试 API Key 创建的核心流程
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent, ref, reactive } from 'vue'
|
||||
|
||||
// Mock keysAPI
|
||||
const mockCreate = vi.fn()
|
||||
const mockList = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
keysAPI: {
|
||||
create: (...args: any[]) => mockCreate(...args),
|
||||
list: (...args: any[]) => mockList(...args),
|
||||
},
|
||||
authAPI: {
|
||||
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
// Mock app store - 使用固定引用确保组件和测试共享同一对象
|
||||
const mockShowSuccess = vi.fn()
|
||||
const mockShowError = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mockShowSuccess,
|
||||
showError: mockShowError,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
/**
|
||||
* 简化的 API Key 创建测试组件
|
||||
*/
|
||||
const ApiKeyCreateTestComponent = defineComponent({
|
||||
setup() {
|
||||
const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const createdKey = ref('')
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
group_id: null as number | null,
|
||||
})
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.name) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await mockCreate({
|
||||
name: formData.name,
|
||||
group_id: formData.group_id,
|
||||
})
|
||||
createdKey.value = result.key
|
||||
appStore.showSuccess('API Key 创建成功')
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.message || '创建失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { formData, loading, createdKey, handleCreate }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<form @submit.prevent="handleCreate">
|
||||
<input id="name" v-model="formData.name" placeholder="Key 名称" />
|
||||
<select id="group" v-model="formData.group_id">
|
||||
<option :value="null">默认</option>
|
||||
<option :value="1">Group 1</option>
|
||||
</select>
|
||||
<button type="submit" :disabled="loading">创建</button>
|
||||
</form>
|
||||
<div v-if="createdKey" class="created-key">{{ createdKey }}</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('ApiKey 创建流程', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('创建 API Key 调用 API 并显示结果', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 1,
|
||||
key: 'sk-test-key-12345',
|
||||
name: 'My Test Key',
|
||||
})
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('My Test Key')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'My Test Key',
|
||||
group_id: null,
|
||||
})
|
||||
|
||||
expect(wrapper.find('.created-key').text()).toBe('sk-test-key-12345')
|
||||
})
|
||||
|
||||
it('选择分组后正确传参', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 2,
|
||||
key: 'sk-group-key',
|
||||
name: 'Group Key',
|
||||
})
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('Group Key')
|
||||
// 选择 group_id = 1
|
||||
await wrapper.find('#group').setValue('1')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Group Key',
|
||||
group_id: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('创建失败时显示错误', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('配额不足'))
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('Fail Key')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockShowError).toHaveBeenCalledWith('配额不足')
|
||||
expect(wrapper.find('.created-key').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('名称为空时不提交', async () => {
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('创建过程中按钮被禁用', async () => {
|
||||
let resolveCreate: (v: any) => void
|
||||
mockCreate.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveCreate = resolve })
|
||||
)
|
||||
|
||||
const wrapper = mount(ApiKeyCreateTestComponent)
|
||||
|
||||
await wrapper.find('#name').setValue('Test Key')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
|
||||
|
||||
resolveCreate!({ id: 1, key: 'sk-test', name: 'Test Key' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
172
frontend/src/components/__tests__/Dashboard.spec.ts
Normal file
172
frontend/src/components/__tests__/Dashboard.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Dashboard 数据加载逻辑测试
|
||||
* 通过封装组件测试仪表板核心数据加载流程
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent, ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
// Mock API
|
||||
const mockGetDashboardStats = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
authAPI: {
|
||||
getCurrentUser: vi.fn().mockResolvedValue({
|
||||
data: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 100, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
|
||||
}),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/usage', () => ({
|
||||
usageAPI: {
|
||||
getDashboardStats: (...args: any[]) => mockGetDashboardStats(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
interface DashboardStats {
|
||||
balance: number
|
||||
api_key_count: number
|
||||
active_api_key_count: number
|
||||
today_requests: number
|
||||
today_cost: number
|
||||
today_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的 Dashboard 测试组件
|
||||
*/
|
||||
const DashboardTestComponent = defineComponent({
|
||||
setup() {
|
||||
const stats = ref<DashboardStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
stats.value = await mockGetDashboardStats()
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
|
||||
return { stats, loading, error, loadStats }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="stats" class="stats">
|
||||
<span class="balance">{{ stats.balance }}</span>
|
||||
<span class="api-keys">{{ stats.api_key_count }}</span>
|
||||
<span class="today-requests">{{ stats.today_requests }}</span>
|
||||
<span class="today-cost">{{ stats.today_cost }}</span>
|
||||
</div>
|
||||
<button class="refresh" @click="loadStats">刷新</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('Dashboard 数据加载', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const fakeStats: DashboardStats = {
|
||||
balance: 100.5,
|
||||
api_key_count: 3,
|
||||
active_api_key_count: 2,
|
||||
today_requests: 150,
|
||||
today_cost: 2.5,
|
||||
today_tokens: 50000,
|
||||
total_tokens: 1000000,
|
||||
}
|
||||
|
||||
it('挂载后自动加载数据', async () => {
|
||||
mockGetDashboardStats.mockResolvedValue(fakeStats)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetDashboardStats).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('.balance').text()).toBe('100.5')
|
||||
expect(wrapper.find('.api-keys').text()).toBe('3')
|
||||
expect(wrapper.find('.today-requests').text()).toBe('150')
|
||||
expect(wrapper.find('.today-cost').text()).toBe('2.5')
|
||||
})
|
||||
|
||||
it('加载中显示 loading 状态', async () => {
|
||||
let resolveStats: (v: any) => void
|
||||
mockGetDashboardStats.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveStats = resolve })
|
||||
)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(true)
|
||||
|
||||
resolveStats!(fakeStats)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(false)
|
||||
expect(wrapper.find('.stats').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('加载失败时显示错误信息', async () => {
|
||||
mockGetDashboardStats.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error').text()).toBe('Network error')
|
||||
expect(wrapper.find('.stats').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('点击刷新按钮重新加载数据', async () => {
|
||||
mockGetDashboardStats.mockResolvedValue(fakeStats)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetDashboardStats).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 更新数据
|
||||
const updatedStats = { ...fakeStats, today_requests: 200 }
|
||||
mockGetDashboardStats.mockResolvedValue(updatedStats)
|
||||
|
||||
await wrapper.find('.refresh').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetDashboardStats).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('.today-requests').text()).toBe('200')
|
||||
})
|
||||
|
||||
it('数据为空时不显示统计信息', async () => {
|
||||
mockGetDashboardStats.mockResolvedValue(null)
|
||||
|
||||
const wrapper = mount(DashboardTestComponent)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.stats').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
178
frontend/src/components/__tests__/LoginForm.spec.ts
Normal file
178
frontend/src/components/__tests__/LoginForm.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* LoginView 组件核心逻辑测试
|
||||
* 测试登录表单提交、验证、2FA 等场景
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent, reactive, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Mock 所有外部依赖
|
||||
const mockLogin = vi.fn()
|
||||
const mockLogin2FA = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
authAPI: {
|
||||
login: (...args: any[]) => mockLogin(...args),
|
||||
login2FA: (...args: any[]) => mockLogin2FA(...args),
|
||||
logout: vi.fn(),
|
||||
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
|
||||
register: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: (response: any) => response?.requires_2fa === true,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
/**
|
||||
* 创建一个简化的测试组件来封装登录逻辑
|
||||
* 避免引入 LoginView.vue 的全部依赖(AuthLayout、i18n、Icon 等)
|
||||
*/
|
||||
const LoginFormTestComponent = defineComponent({
|
||||
setup() {
|
||||
const authStore = useAuthStore()
|
||||
const formData = reactive({ email: '', password: '' })
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formData.email || !formData.password) {
|
||||
errorMessage.value = '请输入邮箱和密码'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
})
|
||||
|
||||
// 2FA 流程由调用方处理
|
||||
if ((response as any)?.requires_2fa) {
|
||||
errorMessage.value = '需要 2FA 验证'
|
||||
return
|
||||
}
|
||||
|
||||
mockPush('/dashboard')
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || '登录失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { formData, isLoading, errorMessage, handleLogin }
|
||||
},
|
||||
template: `
|
||||
<form @submit.prevent="handleLogin">
|
||||
<input id="email" v-model="formData.email" type="email" />
|
||||
<input id="password" v-model="formData.password" type="password" />
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<button type="submit" :disabled="isLoading">登录</button>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('LoginForm 核心逻辑', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('成功登录后跳转到 dashboard', async () => {
|
||||
mockLogin.mockResolvedValue({
|
||||
access_token: 'token',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 0, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
|
||||
})
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLogin).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
})
|
||||
expect(mockPush).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('登录失败时显示错误信息', async () => {
|
||||
mockLogin.mockRejectedValue(new Error('Invalid credentials'))
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('wrong')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error').text()).toBe('Invalid credentials')
|
||||
})
|
||||
|
||||
it('空表单提交显示验证错误', async () => {
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error').text()).toBe('请输入邮箱和密码')
|
||||
expect(mockLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('需要 2FA 时不跳转', async () => {
|
||||
mockLogin.mockResolvedValue({
|
||||
requires_2fa: true,
|
||||
temp_token: 'temp-123',
|
||||
})
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.error').text()).toBe('需要 2FA 验证')
|
||||
})
|
||||
|
||||
it('提交过程中按钮被禁用', async () => {
|
||||
let resolveLogin: (v: any) => void
|
||||
mockLogin.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveLogin = resolve })
|
||||
)
|
||||
|
||||
const wrapper = mount(LoginFormTestComponent)
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
|
||||
|
||||
resolveLogin!({
|
||||
access_token: 'token',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1, username: 'test', email: 'test@example.com', role: 'user', balance: 0, concurrency: 5, status: 'active', allowed_groups: null, created_at: '', updated_at: '' },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="!isSoraAccount" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
@@ -54,6 +54,12 @@
|
||||
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
>
|
||||
{{ t('admin.accounts.soraTestHint') }}
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="group relative">
|
||||
@@ -135,12 +141,12 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="cpu" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chatBubble" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,10 +162,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || !selectedModelId
|
||||
status === 'connecting' || (!isSoraAccount && !selectedModelId)
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
@@ -232,7 +238,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -267,6 +273,7 @@ const availableModels = ref<ClaudeModel[]>([])
|
||||
const selectedModelId = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(
|
||||
@@ -283,6 +290,12 @@ watch(
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
if (!props.account) return
|
||||
if (props.account.platform === 'sora') {
|
||||
availableModels.value = []
|
||||
selectedModelId.value = ''
|
||||
loadingModels.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
@@ -350,7 +363,7 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
@@ -371,7 +384,9 @@ const startTest = async () => {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ model_id: selectedModelId.value })
|
||||
body: JSON.stringify(
|
||||
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
|
||||
)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -428,7 +443,10 @@ const handleEvent = (event: {
|
||||
if (event.model) {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
|
||||
addLine(
|
||||
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
)
|
||||
addLine('', 'text-gray-300')
|
||||
addLine(t('admin.accounts.response'), 'text-yellow-400')
|
||||
break
|
||||
|
||||
@@ -282,6 +282,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
|
||||
@@ -326,153 +327,18 @@ const geminiUsageAvailable = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const codex5hWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '5h'))
|
||||
const codex7dWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '7d'))
|
||||
|
||||
// OpenAI Codex usage computed properties
|
||||
const hasCodexUsage = computed(() => {
|
||||
const extra = props.account.extra
|
||||
return (
|
||||
extra &&
|
||||
// Check for new canonical fields first
|
||||
(extra.codex_5h_used_percent !== undefined ||
|
||||
extra.codex_7d_used_percent !== undefined ||
|
||||
// Fallback to legacy fields
|
||||
extra.codex_primary_used_percent !== undefined ||
|
||||
extra.codex_secondary_used_percent !== undefined)
|
||||
)
|
||||
return codex5hWindow.value.usedPercent !== null || codex7dWindow.value.usedPercent !== null
|
||||
})
|
||||
|
||||
// 5h window usage (prefer canonical field)
|
||||
const codex5hUsedPercent = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_5h_used_percent !== undefined) {
|
||||
return extra.codex_5h_used_percent
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
// Legacy assumption: secondary = 5h (may be incorrect)
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
})
|
||||
|
||||
const codex5hResetAt = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_5h_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_5h_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy assumption: secondary = 5h
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// 7d window usage (prefer canonical field)
|
||||
const codex7dUsedPercent = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_7d_used_percent !== undefined) {
|
||||
return extra.codex_7d_used_percent
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
// Legacy assumption: primary = 7d (may be incorrect)
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
})
|
||||
|
||||
const codex7dResetAt = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra) return null
|
||||
|
||||
// Prefer canonical field
|
||||
if (extra.codex_7d_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_7d_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy assumption: primary = 7d
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent)
|
||||
const codex5hResetAt = computed(() => codex5hWindow.value.resetAt)
|
||||
const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent)
|
||||
const codex7dResetAt = computed(() => codex7dWindow.value.resetAt)
|
||||
|
||||
// Antigravity quota types (用于 API 返回的数据)
|
||||
interface AntigravityUsageResult {
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -695,6 +696,7 @@ const baseUrl = ref('')
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
const modelMappings = ref<ModelMapping[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('bulk-model-mapping')
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
@@ -717,6 +719,7 @@ const allModels = [
|
||||
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
||||
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
||||
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
|
||||
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
@@ -768,6 +771,12 @@ const presetMappings = [
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.3 Codex Spark',
|
||||
from: 'gpt-5.3-codex-spark',
|
||||
to: 'gpt-5.3-codex-spark',
|
||||
color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.2',
|
||||
from: 'gpt-5.2-2025-12-11',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,77 +69,30 @@
|
||||
<div v-if="account.platform !== 'gemini' && account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
<div
|
||||
v-if="isOpenAIModelRestrictionDisabled"
|
||||
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||||
>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<template v-else>
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -148,18 +101,75 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -225,19 +235,20 @@
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes Section -->
|
||||
@@ -406,7 +417,7 @@
|
||||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in antigravityModelMappings"
|
||||
:key="index"
|
||||
:key="getAntigravityModelMappingKey(mapping)"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -531,7 +542,7 @@
|
||||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in tempUnschedRules"
|
||||
:key="index"
|
||||
:key="getTempUnschedRuleKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
@@ -694,6 +705,96 @@
|
||||
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI 自动透传开关(OAuth/API Key) -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.oauthPassthrough') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.oauthPassthroughDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openaiPassthroughEnabled = !openaiPassthroughEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic API Key 自动透传开关 -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.apiKeyPassthrough') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.anthropic.apiKeyPassthroughDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="anthropicPassthroughEnabled = !anthropicPassthroughEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
anthropicPassthroughEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
anthropicPassthroughEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnly') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.codexCLIOnlyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="codexCLIOnlyEnabled = !codexCLIOnlyEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
codexCLIOnlyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
codexCLIOnlyEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -1062,6 +1163,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import {
|
||||
getPresetMappingsByPlatform,
|
||||
commonErrorCodes,
|
||||
@@ -1079,7 +1181,7 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
updated: []
|
||||
updated: [account: Account]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -1127,6 +1229,9 @@ const antigravityWhitelistModels = ref<string[]>([])
|
||||
const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
|
||||
|
||||
// Mixed channel warning dialog state
|
||||
const showMixedChannelWarning = ref(false)
|
||||
@@ -1145,6 +1250,14 @@ const sessionIdMaskingEnabled = ref(false)
|
||||
const cacheTTLOverrideEnabled = ref(false)
|
||||
const cacheTTLOverrideTarget = ref<string>('5m')
|
||||
|
||||
// OpenAI 自动透传开关(OAuth/API Key)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const anthropicPassthroughEnabled = ref(false)
|
||||
const isOpenAIModelRestrictionDisabled = computed(() =>
|
||||
props.account?.platform === 'openai' && openaiPassthroughEnabled.value
|
||||
)
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
const tempUnschedPresets = computed(() => [
|
||||
@@ -1232,6 +1345,20 @@ watch(
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
codexCLIOnlyEnabled.value = false
|
||||
anthropicPassthroughEnabled.value = false
|
||||
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
|
||||
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||
if (newAccount.type === 'oauth') {
|
||||
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
|
||||
}
|
||||
}
|
||||
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
|
||||
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
|
||||
}
|
||||
|
||||
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
||||
if (newAccount.platform === 'antigravity') {
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
@@ -1625,7 +1752,7 @@ const handleSubmit = async () => {
|
||||
if (props.account.type === 'apikey') {
|
||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
const shouldApplyModelMapping = !(props.account.platform === 'openai' && openaiPassthroughEnabled.value)
|
||||
|
||||
// Always update credentials for apikey type to handle model mapping changes
|
||||
const newCredentials: Record<string, unknown> = {
|
||||
@@ -1645,9 +1772,14 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Add model mapping if configured
|
||||
if (modelMapping) {
|
||||
newCredentials.model_mapping = modelMapping
|
||||
// Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑)
|
||||
if (shouldApplyModelMapping) {
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
if (modelMapping) {
|
||||
newCredentials.model_mapping = modelMapping
|
||||
}
|
||||
} else if (currentCredentials.model_mapping) {
|
||||
newCredentials.model_mapping = currentCredentials.model_mapping
|
||||
}
|
||||
|
||||
// Add custom error codes if enabled
|
||||
@@ -1785,9 +1917,47 @@ const handleSubmit = async () => {
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
await adminAPI.accounts.update(props.account.id, updatePayload)
|
||||
// For Anthropic API Key accounts, handle passthrough mode in extra
|
||||
if (props.account.platform === 'anthropic' && props.account.type === 'apikey') {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
if (anthropicPassthroughEnabled.value) {
|
||||
newExtra.anthropic_passthrough = true
|
||||
} else {
|
||||
delete newExtra.anthropic_passthrough
|
||||
}
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
// For OpenAI OAuth/API Key accounts, handle passthrough mode in extra
|
||||
if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true
|
||||
if (openaiPassthroughEnabled.value) {
|
||||
newExtra.openai_passthrough = true
|
||||
} else {
|
||||
delete newExtra.openai_passthrough
|
||||
delete newExtra.openai_oauth_passthrough
|
||||
}
|
||||
|
||||
if (props.account.type === 'oauth') {
|
||||
if (codexCLIOnlyEnabled.value) {
|
||||
newExtra.codex_cli_only = true
|
||||
} else if (hadCodexCLIOnlyEnabled) {
|
||||
// 关闭时显式写 false,避免 extra 为空被后端忽略导致旧值无法清除
|
||||
newExtra.codex_cli_only = false
|
||||
} else {
|
||||
delete newExtra.codex_cli_only
|
||||
}
|
||||
}
|
||||
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
const updatedAccount = await adminAPI.accounts.update(props.account.id, updatePayload)
|
||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||
emit('updated')
|
||||
emit('updated', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
// Handle 409 mixed_channel_warning - show confirmation dialog
|
||||
@@ -1815,9 +1985,9 @@ const handleMixedChannelConfirm = async () => {
|
||||
pendingUpdatePayload.value.confirm_mixed_channel_risk = true
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value)
|
||||
const updatedAccount = await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value)
|
||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||
emit('updated')
|
||||
emit('updated', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
||||
|
||||
@@ -144,11 +144,17 @@ const showDropdown = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const customModel = ref('')
|
||||
const isComposing = ref(false)
|
||||
const availableOptions = computed(() => {
|
||||
if (props.platform === 'sora') {
|
||||
return getModelsByPlatform('sora').map(m => ({ value: m, label: m }))
|
||||
}
|
||||
return allModels
|
||||
})
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
if (!query) return allModels
|
||||
return allModels.filter(
|
||||
if (!query) return availableOptions.value
|
||||
return availableOptions.value.filter(
|
||||
m => m.value.toLowerCase().includes(query) || m.label.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
@@ -197,4 +203,5 @@ const fillRelated = () => {
|
||||
const clearAll = () => {
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -48,6 +48,17 @@
|
||||
t(getOAuthKey('refreshTokenAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showSessionTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="session_token"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t(getOAuthKey('sessionTokenAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,6 +146,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Token Input (Sora) -->
|
||||
<div v-if="inputMethod === 'session_token'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t(getOAuthKey('sessionTokenDesc')) }}
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
Session Token
|
||||
<span
|
||||
v-if="parsedSessionTokenCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedSessionTokenCount }) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="sessionTokenInput"
|
||||
rows="3"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t(getOAuthKey('sessionTokenPlaceholder'))"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedSessionTokenCount > 1"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedSessionTokenCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !sessionTokenInput.trim()"
|
||||
@click="handleValidateSessionToken"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||
{{
|
||||
loading
|
||||
? t(getOAuthKey('validating'))
|
||||
: t(getOAuthKey('validateAndCreate'))
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie Auto-Auth Form -->
|
||||
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
||||
<div
|
||||
@@ -511,6 +603,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import type { AccountPlatform } from '@/types'
|
||||
|
||||
interface Props {
|
||||
addMethod: AddMethod
|
||||
@@ -524,7 +617,8 @@ interface Props {
|
||||
methodLabel?: string
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
|
||||
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
|
||||
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
|
||||
platform?: AccountPlatform // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
|
||||
@@ -539,6 +633,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
methodLabel: 'Authorization Method',
|
||||
showCookieOption: true,
|
||||
showRefreshTokenOption: false,
|
||||
showSessionTokenOption: false,
|
||||
platform: 'anthropic',
|
||||
showProjectId: true
|
||||
})
|
||||
@@ -548,16 +643,17 @@ const emit = defineEmits<{
|
||||
'exchange-code': [code: string]
|
||||
'cookie-auth': [sessionKey: string]
|
||||
'validate-refresh-token': [refreshToken: string]
|
||||
'validate-session-token': [sessionToken: string]
|
||||
'update:inputMethod': [method: AuthInputMethod]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpenAI = computed(() => props.platform === 'openai')
|
||||
const isOpenAI = computed(() => props.platform === 'openai' || props.platform === 'sora')
|
||||
|
||||
// Get translation key based on platform
|
||||
const getOAuthKey = (key: string) => {
|
||||
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'openai' || props.platform === 'sora') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
|
||||
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
|
||||
return `admin.accounts.oauth.${key}`
|
||||
@@ -576,7 +672,7 @@ const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
|
||||
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
|
||||
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
|
||||
const oauthImportantNotice = computed(() => {
|
||||
if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'openai' || props.platform === 'sora') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
|
||||
return ''
|
||||
})
|
||||
@@ -586,12 +682,13 @@ const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'ma
|
||||
const authCodeInput = ref('')
|
||||
const sessionKeyInput = ref('')
|
||||
const refreshTokenInput = ref('')
|
||||
const sessionTokenInput = ref('')
|
||||
const showHelpDialog = ref(false)
|
||||
const oauthState = ref('')
|
||||
const projectId = ref('')
|
||||
|
||||
// Computed: show method selection when either cookie or refresh token option is enabled
|
||||
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption)
|
||||
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption)
|
||||
|
||||
// Clipboard
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
@@ -612,6 +709,13 @@ const parsedRefreshTokenCount = computed(() => {
|
||||
.filter((rt) => rt).length
|
||||
})
|
||||
|
||||
const parsedSessionTokenCount = computed(() => {
|
||||
return sessionTokenInput.value
|
||||
.split('\n')
|
||||
.map((st) => st.trim())
|
||||
.filter((st) => st).length
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
@@ -620,7 +724,7 @@ watch(inputMethod, (newVal) => {
|
||||
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
|
||||
// e.g., http://localhost:8085/callback?code=xxx...&state=...
|
||||
watch(authCodeInput, (newVal) => {
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'sora') return
|
||||
|
||||
const trimmed = newVal.trim()
|
||||
// Check if it looks like a URL with code parameter
|
||||
@@ -630,7 +734,7 @@ watch(authCodeInput, (newVal) => {
|
||||
const url = new URL(trimmed)
|
||||
const code = url.searchParams.get('code')
|
||||
const stateParam = url.searchParams.get('state')
|
||||
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
oauthState.value = stateParam
|
||||
}
|
||||
if (code && code !== trimmed) {
|
||||
@@ -641,7 +745,7 @@ watch(authCodeInput, (newVal) => {
|
||||
// If URL parsing fails, try regex extraction
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/)
|
||||
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
|
||||
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
oauthState.value = stateMatch[1]
|
||||
}
|
||||
if (match && match[1] && match[1] !== trimmed) {
|
||||
@@ -679,6 +783,12 @@ const handleValidateRefreshToken = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateSessionToken = () => {
|
||||
if (sessionTokenInput.value.trim()) {
|
||||
emit('validate-session-token', sessionTokenInput.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods and state
|
||||
defineExpose({
|
||||
authCode: authCodeInput,
|
||||
@@ -686,6 +796,7 @@ defineExpose({
|
||||
projectId,
|
||||
sessionKey: sessionKeyInput,
|
||||
refreshToken: refreshTokenInput,
|
||||
sessionToken: sessionTokenInput,
|
||||
inputMethod,
|
||||
reset: () => {
|
||||
authCodeInput.value = ''
|
||||
@@ -693,6 +804,7 @@ defineExpose({
|
||||
projectId.value = ''
|
||||
sessionKeyInput.value = ''
|
||||
refreshTokenInput.value = ''
|
||||
sessionTokenInput.value = ''
|
||||
inputMethod.value = 'manual'
|
||||
showHelpDialog.value = false
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI
|
||||
isOpenAILike
|
||||
? 'from-green-500 to-green-600'
|
||||
: isGemini
|
||||
? 'from-blue-500 to-blue-600'
|
||||
@@ -33,6 +33,8 @@
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isSora
|
||||
? t('admin.accounts.soraAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
@@ -128,7 +130,7 @@
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@@ -224,7 +226,8 @@ const { t } = useI18n()
|
||||
|
||||
// OAuth composables
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
|
||||
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
|
||||
@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
|
||||
|
||||
// Computed - check platform
|
||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||
const isSora = computed(() => props.account?.platform === 'sora')
|
||||
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
|
||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.loading.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.error.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
@@ -269,8 +275,8 @@ const currentError = computed(() => {
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
@@ -313,6 +319,7 @@ const resetState = () => {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
@@ -325,8 +332,8 @@ const handleClose = () => {
|
||||
const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
if (isOpenAILike.value) {
|
||||
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
|
||||
@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (!authCode.trim()) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
if (isOpenAILike.value) {
|
||||
// OpenAI OAuth flow
|
||||
const sessionId = openaiOAuth.sessionId.value
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const sessionId = oauthClient.sessionId.value
|
||||
if (!sessionId) return
|
||||
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
|
||||
if (!stateToUse) {
|
||||
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
const tokenInfo = await oauthClient.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
sessionId,
|
||||
stateToUse,
|
||||
props.account.proxy_id
|
||||
)
|
||||
if (!tokenInfo) return
|
||||
|
||||
// Build credentials and extra info
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
const credentials = oauthClient.buildCredentials(tokenInfo)
|
||||
const extra = oauthClient.buildExtraInfo(tokenInfo)
|
||||
|
||||
try {
|
||||
// Update account with new credentials
|
||||
@@ -376,8 +391,8 @@ const handleExchangeCode = async () => {
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
}
|
||||
} else if (isGemini.value) {
|
||||
const sessionId = geminiOAuth.sessionId.value
|
||||
@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
|
||||
}
|
||||
|
||||
const handleCookieAuth = async (sessionKey: string) => {
|
||||
if (!props.account || isOpenAI.value) return
|
||||
if (!props.account || isOpenAILike.value) return
|
||||
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
|
||||
@@ -23,7 +23,7 @@ const updatePlatform = (value: string | number | boolean | null) => { emit('upda
|
||||
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
|
||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }])
|
||||
const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="!isSoraAccount" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
@@ -54,6 +54,12 @@
|
||||
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
>
|
||||
{{ t('admin.accounts.soraTestHint') }}
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="group relative">
|
||||
@@ -114,12 +120,12 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="grid" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,10 +141,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:disabled="status === 'connecting' || (!isSoraAccount && !selectedModelId)"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || !selectedModelId
|
||||
status === 'connecting' || (!isSoraAccount && !selectedModelId)
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
@@ -172,7 +178,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -207,6 +213,7 @@ const availableModels = ref<ClaudeModel[]>([])
|
||||
const selectedModelId = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(
|
||||
@@ -223,6 +230,12 @@ watch(
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
if (!props.account) return
|
||||
if (props.account.platform === 'sora') {
|
||||
availableModels.value = []
|
||||
selectedModelId.value = ''
|
||||
loadingModels.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
@@ -290,7 +303,7 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
if (!props.account || (!isSoraAccount.value && !selectedModelId.value)) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
@@ -311,7 +324,9 @@ const startTest = async () => {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ model_id: selectedModelId.value })
|
||||
body: JSON.stringify(
|
||||
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
|
||||
)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -368,7 +383,10 @@ const handleEvent = (event: {
|
||||
if (event.model) {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
|
||||
addLine(
|
||||
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
)
|
||||
addLine('', 'text-gray-300')
|
||||
addLine(t('admin.accounts.response'), 'text-yellow-400')
|
||||
break
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI
|
||||
isOpenAILike
|
||||
? 'from-green-500 to-green-600'
|
||||
: isGemini
|
||||
? 'from-blue-500 to-blue-600'
|
||||
@@ -33,6 +33,8 @@
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isSora
|
||||
? t('admin.accounts.soraAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
@@ -128,7 +130,7 @@
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isSora ? 'sora' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
@@ -216,7 +218,7 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
reauthorized: []
|
||||
reauthorized: [account: Account]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
@@ -224,7 +226,8 @@ const { t } = useI18n()
|
||||
|
||||
// OAuth composables
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth({ platform: 'openai' })
|
||||
const soraOAuth = useOpenAIOAuth({ platform: 'sora' })
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
|
||||
@@ -237,31 +240,34 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
|
||||
|
||||
// Computed - check platform
|
||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||
const isSora = computed(() => props.account?.platform === 'sora')
|
||||
const isOpenAILike = computed(() => isOpenAI.value || isSora.value)
|
||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||
const activeOpenAIOAuth = computed(() => (isSora.value ? soraOAuth : openaiOAuth))
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.loading.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.error.value
|
||||
if (isOpenAILike.value) return activeOpenAIOAuth.value.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
@@ -269,8 +275,8 @@ const currentError = computed(() => {
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
// OpenAI/Sora/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
@@ -313,6 +319,7 @@ const resetState = () => {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
soraOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
@@ -325,8 +332,8 @@ const handleClose = () => {
|
||||
const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
if (isOpenAILike.value) {
|
||||
await activeOpenAIOAuth.value.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
|
||||
@@ -345,21 +352,29 @@ const handleExchangeCode = async () => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (!authCode.trim()) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
if (isOpenAILike.value) {
|
||||
// OpenAI OAuth flow
|
||||
const sessionId = openaiOAuth.sessionId.value
|
||||
const oauthClient = activeOpenAIOAuth.value
|
||||
const sessionId = oauthClient.sessionId.value
|
||||
if (!sessionId) return
|
||||
const stateToUse = (oauthFlowRef.value?.oauthState || oauthClient.oauthState.value || '').trim()
|
||||
if (!stateToUse) {
|
||||
oauthClient.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
const tokenInfo = await oauthClient.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
sessionId,
|
||||
stateToUse,
|
||||
props.account.proxy_id
|
||||
)
|
||||
if (!tokenInfo) return
|
||||
|
||||
// Build credentials and extra info
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
const credentials = oauthClient.buildCredentials(tokenInfo)
|
||||
const extra = oauthClient.buildExtraInfo(tokenInfo)
|
||||
|
||||
try {
|
||||
// Update account with new credentials
|
||||
@@ -370,14 +385,14 @@ const handleExchangeCode = async () => {
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
oauthClient.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauthClient.error.value)
|
||||
}
|
||||
} else if (isGemini.value) {
|
||||
const sessionId = geminiOAuth.sessionId.value
|
||||
@@ -404,9 +419,9 @@ const handleExchangeCode = async () => {
|
||||
type: 'oauth',
|
||||
credentials
|
||||
})
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
@@ -436,9 +451,9 @@ const handleExchangeCode = async () => {
|
||||
type: 'oauth',
|
||||
credentials
|
||||
})
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
@@ -475,10 +490,10 @@ const handleExchangeCode = async () => {
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
@@ -490,7 +505,7 @@ const handleExchangeCode = async () => {
|
||||
}
|
||||
|
||||
const handleCookieAuth = async (sessionKey: string) => {
|
||||
if (!props.account || isOpenAI.value) return
|
||||
if (!props.account || isOpenAILike.value) return
|
||||
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
@@ -518,10 +533,10 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
emit('reauthorized', updatedAccount)
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value =
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.proxies.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.proxies.importData({ data: dataPayload })
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-user_agent="{ row }">
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] whitespace-normal break-all" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
@@ -313,16 +313,7 @@ const formatCacheTokens = (tokens: number): string => {
|
||||
}
|
||||
|
||||
const formatUserAgent = (ua: string): string => {
|
||||
// 提取主要客户端标识
|
||||
if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI'
|
||||
if (ua.includes('Cursor')) return 'Cursor'
|
||||
if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code'
|
||||
if (ua.includes('Continue')) return 'Continue'
|
||||
if (ua.includes('Cline')) return 'Cline'
|
||||
if (ua.includes('OpenAI')) return 'OpenAI SDK'
|
||||
if (ua.includes('anthropic')) return 'Anthropic SDK'
|
||||
// 截断过长的 UA
|
||||
return ua.length > 30 ? ua.substring(0, 30) + '...' : ua
|
||||
return ua
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number | null | undefined): string => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="space-y-3">
|
||||
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
|
||||
<div v-for="column in dataColumns" :key="column.key" class="flex justify-between">
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="column in columns.filter(c => c.key !== 'actions')"
|
||||
v-for="column in dataColumns"
|
||||
:key="column.key"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
|
||||
return key ?? index
|
||||
}
|
||||
|
||||
const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions'))
|
||||
const columnsSignature = computed(() =>
|
||||
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
|
||||
)
|
||||
|
||||
// 数据/列变化时重新检查滚动状态
|
||||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||
watch(
|
||||
[() => props.data.length, () => props.columns],
|
||||
[() => props.data.length, columnsSignature],
|
||||
async () => {
|
||||
await nextTick()
|
||||
checkScrollable()
|
||||
@@ -555,7 +560,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.columns,
|
||||
columnsSignature,
|
||||
() => {
|
||||
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||
const normalized = normalizeSortKey(sortKey.value)
|
||||
@@ -575,7 +580,7 @@ watch(
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
|
||||
@@ -116,6 +116,9 @@ const labelClass = computed(() => {
|
||||
if (props.platform === 'gemini') {
|
||||
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return `${base} bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
|
||||
}
|
||||
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
|
||||
})
|
||||
|
||||
@@ -137,6 +140,11 @@ const badgeClass = computed(() => {
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return isSubscription.value
|
||||
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
: 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
|
||||
}
|
||||
// Fallback: original colors
|
||||
return isSubscription.value
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
:disabled="switching"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
:title="currentLocale?.name"
|
||||
>
|
||||
@@ -23,6 +24,7 @@
|
||||
<button
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale.code"
|
||||
:disabled="switching"
|
||||
@click="selectLocale(locale.code)"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
|
||||
:class="{
|
||||
@@ -49,6 +51,7 @@ const { locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const switching = ref(false)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||
@@ -57,9 +60,18 @@ function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function selectLocale(code: string) {
|
||||
setLocale(code)
|
||||
isOpen.value = false
|
||||
async function selectLocale(code: string) {
|
||||
if (switching.value || code === currentLocaleCode.value) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
switching.value = true
|
||||
try {
|
||||
await setLocale(code)
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
|
||||
<!-- Page numbers -->
|
||||
<button
|
||||
v-for="pageNum in visiblePages"
|
||||
:key="pageNum"
|
||||
v-for="(pageNum, index) in visiblePages"
|
||||
:key="`${pageNum}-${index}`"
|
||||
@click="typeof pageNum === 'number' && goToPage(pageNum)"
|
||||
:disabled="typeof pageNum !== 'number'"
|
||||
:class="[
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
||||
</svg>
|
||||
<!-- Sora logo (sparkle) -->
|
||||
<svg v-else-if="platform === 'sora'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2.5l2.1 4.7 5.1.5-3.9 3.4 1.2 5-4.5-2.6-4.5 2.6 1.2-5-3.9-3.4 5.1-.5L12 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Fallback: generic platform icon -->
|
||||
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
@@ -48,6 +48,7 @@ const platformLabel = computed(() => {
|
||||
if (props.platform === 'anthropic') return 'Anthropic'
|
||||
if (props.platform === 'openai') return 'OpenAI'
|
||||
if (props.platform === 'antigravity') return 'Antigravity'
|
||||
if (props.platform === 'sora') return 'Sora'
|
||||
return 'Gemini'
|
||||
})
|
||||
|
||||
@@ -74,6 +75,9 @@ const platformClass = computed(() => {
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
|
||||
@@ -87,6 +91,9 @@ const typeClass = computed(() => {
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
if (props.platform === 'sora') {
|
||||
return 'bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
<!-- Progress bar -->
|
||||
<div v-if="toast.duration" class="h-1 bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full transition-all', getProgressBarColor(toast.type)]"
|
||||
:style="{ width: `${getProgress(toast)}%` }"
|
||||
:class="['h-full toast-progress', getProgressBarColor(toast.type)]"
|
||||
:style="{ animationDuration: `${toast.duration}ms` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
@@ -129,36 +129,25 @@ const getProgressBarColor = (type: string): string => {
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
const getProgress = (toast: any): number => {
|
||||
if (!toast.duration || !toast.startTime) return 100
|
||||
const elapsed = Date.now() - toast.startTime
|
||||
const progress = Math.max(0, 100 - (elapsed / toast.duration) * 100)
|
||||
return progress
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
appStore.hideToast(id)
|
||||
}
|
||||
|
||||
let intervalId: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
// Check for expired toasts every 100ms
|
||||
intervalId = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
toasts.value.forEach((toast) => {
|
||||
if (toast.duration && toast.startTime) {
|
||||
if (now - toast.startTime >= toast.duration) {
|
||||
removeToast(toast.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId !== undefined) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-progress {
|
||||
width: 100%;
|
||||
animation-name: toast-progress-shrink;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress-shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -534,6 +534,18 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
const openaiModels = {
|
||||
'gpt-5.3-codex-spark': {
|
||||
name: 'GPT-5.3 Codex Spark',
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {},
|
||||
xhigh: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.2-codex': {
|
||||
name: 'GPT-5.2 Codex',
|
||||
options: {
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
<!-- Options (for select/multi_select) -->
|
||||
<div v-if="form.type === 'select' || form.type === 'multi_select'" class="space-y-2">
|
||||
<label class="input-label">{{ t('admin.users.attributes.options') }}</label>
|
||||
<div v-for="(option, index) in form.options" :key="index" class="flex items-center gap-2">
|
||||
<div v-for="(option, index) in form.options" :key="getOptionKey(option)" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="option.value"
|
||||
type="text"
|
||||
@@ -246,6 +246,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -270,6 +271,7 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const deletingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const getOptionKey = createStableObjectKeyResolver<UserAttributeOption>('user-attr-option')
|
||||
|
||||
const form = reactive({
|
||||
key: '',
|
||||
@@ -315,7 +317,7 @@ const openEditModal = (attr: UserAttributeDefinition) => {
|
||||
form.placeholder = attr.placeholder || ''
|
||||
form.required = attr.required
|
||||
form.enabled = attr.enabled
|
||||
form.options = attr.options ? [...attr.options] : []
|
||||
form.options = attr.options ? attr.options.map((opt) => ({ ...opt })) : []
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -107,6 +107,7 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const form = ref({
|
||||
emailCode: '',
|
||||
password: ''
|
||||
@@ -139,10 +140,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -176,4 +184,11 @@ const handleDisable = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -198,6 +198,7 @@ const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
@@ -338,10 +339,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -397,4 +405,11 @@ const handleVerify = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TotpSetupModal from '@/components/user/profile/TotpSetupModal.vue'
|
||||
import TotpDisableDialog from '@/components/user/profile/TotpDisableDialog.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
getVerificationMethod: vi.fn(),
|
||||
sendVerifyCode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mocks.showSuccess,
|
||||
showError: mocks.showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
totpAPI: {
|
||||
getVerificationMethod: mocks.getVerificationMethod,
|
||||
sendVerifyCode: mocks.sendVerifyCode,
|
||||
initiateSetup: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const flushPromises = async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('TOTP 弹窗定时器清理', () => {
|
||||
let intervalSeed = 1000
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
intervalSeed = 1000
|
||||
mocks.showSuccess.mockReset()
|
||||
mocks.showError.mockReset()
|
||||
mocks.getVerificationMethod.mockReset()
|
||||
mocks.sendVerifyCode.mockReset()
|
||||
|
||||
mocks.getVerificationMethod.mockResolvedValue({ method: 'email' })
|
||||
mocks.sendVerifyCode.mockResolvedValue({ success: true })
|
||||
|
||||
setIntervalSpy = vi.spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => {
|
||||
void handler
|
||||
intervalSeed += 1
|
||||
return intervalSeed as unknown as number
|
||||
}) as typeof window.setInterval)
|
||||
clearIntervalSpy = vi.spyOn(window, 'clearInterval')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setIntervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('TotpSetupModal 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpSetupModal)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
|
||||
it('TotpDisableDialog 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpDisableDialog)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
})
|
||||
143
frontend/src/composables/__tests__/useClipboard.spec.ts
Normal file
143
frontend/src/composables/__tests__/useClipboard.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock app store
|
||||
const mockShowSuccess = vi.fn()
|
||||
const mockShowError = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mockShowSuccess,
|
||||
showError: mockShowError,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
describe('useClipboard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// 默认模拟安全上下文 + Clipboard API
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
// 恢复 execCommand
|
||||
if ('execCommand' in document) {
|
||||
delete (document as any).execCommand
|
||||
}
|
||||
})
|
||||
|
||||
it('复制成功后 copied 变为 true', async () => {
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
expect(copied.value).toBe(false)
|
||||
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(copied.value).toBe(true)
|
||||
})
|
||||
|
||||
it('copied 在 2 秒后自动恢复为 false', async () => {
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello')
|
||||
expect(copied.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
expect(copied.value).toBe(false)
|
||||
})
|
||||
|
||||
it('复制成功时调用 showSuccess', async () => {
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello', '已复制')
|
||||
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith('已复制')
|
||||
})
|
||||
|
||||
it('无自定义消息时使用 i18n 默认消息', async () => {
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith('common.copiedToClipboard')
|
||||
})
|
||||
|
||||
it('空文本返回 false 且不复制', async () => {
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
|
||||
const result = await copyToClipboard('')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(copied.value).toBe(false)
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Clipboard API 失败时降级到 fallback', async () => {
|
||||
const writeTextMock = navigator.clipboard.writeText as any
|
||||
writeTextMock.mockRejectedValue(new Error('API failed'))
|
||||
|
||||
// jsdom 没有 execCommand,手动定义
|
||||
const documentAny = document as any
|
||||
documentAny.execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('fallback text')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(copied.value).toBe(true)
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('非安全上下文使用 fallback', async () => {
|
||||
Object.defineProperty(window, 'isSecureContext', { value: false, writable: true })
|
||||
|
||||
const documentAny = document as any
|
||||
documentAny.execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('insecure context text')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(copied.value).toBe(true)
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('所有复制方式均失败时调用 showError', async () => {
|
||||
const writeTextMock = navigator.clipboard.writeText as any
|
||||
writeTextMock.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const documentAny = document as any
|
||||
documentAny.execCommand = vi.fn().mockReturnValue(false)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('text')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(copied.value).toBe(false)
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
143
frontend/src/composables/__tests__/useForm.spec.ts
Normal file
143
frontend/src/composables/__tests__/useForm.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useForm } from '@/composables/useForm'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
// Mock API 依赖(app store 内部引用了这些)
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useForm', () => {
|
||||
let appStore: ReturnType<typeof useAppStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appStore = useAppStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('submit 期间 loading 为 true,完成后为 false', async () => {
|
||||
let resolveSubmit: () => void
|
||||
const submitFn = vi.fn(
|
||||
() => new Promise<void>((resolve) => { resolveSubmit = resolve })
|
||||
)
|
||||
|
||||
const { loading, submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
|
||||
const submitPromise = submit()
|
||||
// 提交中
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveSubmit!()
|
||||
await submitPromise
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('submit 成功时显示成功消息', async () => {
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
const showSuccessSpy = vi.spyOn(appStore, 'showSuccess')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
successMsg: '保存成功',
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(showSuccessSpy).toHaveBeenCalledWith('保存成功')
|
||||
})
|
||||
|
||||
it('submit 成功但无 successMsg 时不调用 showSuccess', async () => {
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
const showSuccessSpy = vi.spyOn(appStore, 'showSuccess')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(showSuccessSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submit 失败时显示错误消息并抛出错误', async () => {
|
||||
const error = Object.assign(new Error('提交失败'), {
|
||||
response: { data: { message: '服务器错误' } },
|
||||
})
|
||||
const submitFn = vi.fn().mockRejectedValue(error)
|
||||
const showErrorSpy = vi.spyOn(appStore, 'showError')
|
||||
|
||||
const { submit, loading } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await expect(submit()).rejects.toThrow('提交失败')
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalled()
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('submit 失败时使用自定义 errorMsg', async () => {
|
||||
const submitFn = vi.fn().mockRejectedValue(new Error('network'))
|
||||
const showErrorSpy = vi.spyOn(appStore, 'showError')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
errorMsg: '自定义错误提示',
|
||||
})
|
||||
|
||||
await expect(submit()).rejects.toThrow()
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalledWith('自定义错误提示')
|
||||
})
|
||||
|
||||
it('loading 中不会重复提交', async () => {
|
||||
let resolveSubmit: () => void
|
||||
const submitFn = vi.fn(
|
||||
() => new Promise<void>((resolve) => { resolveSubmit = resolve })
|
||||
)
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
// 第一次提交
|
||||
const p1 = submit()
|
||||
// 第二次提交(应被忽略,因为 loading=true)
|
||||
submit()
|
||||
|
||||
expect(submitFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSubmit!()
|
||||
await p1
|
||||
})
|
||||
|
||||
it('传递 form 数据到 submitFn', async () => {
|
||||
const formData = { name: 'test', email: 'test@example.com' }
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { submit } = useForm({
|
||||
form: formData,
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(submitFn).toHaveBeenCalledWith(formData)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
|
||||
const flushPromises = () => Promise.resolve()
|
||||
|
||||
describe('useKeyedDebouncedSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('为不同 key 独立防抖触发搜索', async () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.trigger('b', 'bar')
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'foo',
|
||||
expect.objectContaining({ key: 'a', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'bar',
|
||||
expect.objectContaining({ key: 'b', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('同 key 新请求会取消旧请求并忽略过期响应', async () => {
|
||||
const resolves: Array<(value: string[]) => void> = []
|
||||
const search = vi.fn().mockImplementation(
|
||||
() => new Promise<string[]>((resolve) => {
|
||||
resolves.push(resolve)
|
||||
})
|
||||
)
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 50,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('rule-1', 'first')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
searcher.trigger('rule-1', 'second')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
|
||||
resolves[1](['second'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onSuccess).toHaveBeenLastCalledWith('rule-1', ['second'])
|
||||
|
||||
resolves[0](['first'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clearKey 会取消未执行任务', () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.clearKey('a')
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
251
frontend/src/composables/__tests__/useTableLoader.spec.ts
Normal file
251
frontend/src/composables/__tests__/useTableLoader.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useTableLoader } from '@/composables/useTableLoader'
|
||||
|
||||
// Mock @vueuse/core 的 useDebounceFn
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDebounceFn: (fn: Function, ms: number) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = (...args: any[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => fn(...args), ms)
|
||||
}
|
||||
debounced.cancel = () => { if (timer) clearTimeout(timer) }
|
||||
return debounced
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Vue 的 onUnmounted(composable 外使用时会报错)
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
onUnmounted: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const createMockFetchFn = (items: any[] = [], total = 0, pages = 1) => {
|
||||
return vi.fn().mockResolvedValue({ items, total, pages })
|
||||
}
|
||||
|
||||
describe('useTableLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// --- 基础加载 ---
|
||||
|
||||
describe('基础加载', () => {
|
||||
it('load 执行 fetchFn 并更新 items', async () => {
|
||||
const mockItems = [{ id: 1, name: 'item1' }, { id: 2, name: 'item2' }]
|
||||
const fetchFn = createMockFetchFn(mockItems, 2, 1)
|
||||
|
||||
const { items, loading, load, pagination } = useTableLoader({
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(items.value).toHaveLength(0)
|
||||
|
||||
await load()
|
||||
|
||||
expect(items.value).toEqual(mockItems)
|
||||
expect(pagination.total).toBe(2)
|
||||
expect(pagination.pages).toBe(1)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('load 期间 loading 为 true', async () => {
|
||||
let resolveLoad: (v: any) => void
|
||||
const fetchFn = vi.fn(
|
||||
() => new Promise((resolve) => { resolveLoad = resolve })
|
||||
)
|
||||
|
||||
const { loading, load } = useTableLoader({ fetchFn })
|
||||
|
||||
const p = load()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveLoad!({ items: [], total: 0, pages: 0 })
|
||||
await p
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('使用默认 pageSize=20', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { load, pagination } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(pagination.page_size).toBe(20)
|
||||
})
|
||||
|
||||
it('可自定义 pageSize', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { load } = useTableLoader({ fetchFn, pageSize: 50 })
|
||||
|
||||
await load()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 分页 ---
|
||||
|
||||
describe('分页', () => {
|
||||
it('handlePageChange 更新页码并加载', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load() // 初始加载
|
||||
fetchFn.mockClear()
|
||||
|
||||
handlePageChange(3)
|
||||
|
||||
expect(pagination.page).toBe(3)
|
||||
// 等待 load 完成
|
||||
await vi.runAllTimersAsync()
|
||||
expect(fetchFn).toHaveBeenCalledWith(3, 20, expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('handlePageSizeChange 重置到第1页并加载', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageSizeChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
pagination.page = 3
|
||||
fetchFn.mockClear()
|
||||
|
||||
handlePageSizeChange(50)
|
||||
|
||||
expect(pagination.page).toBe(1)
|
||||
expect(pagination.page_size).toBe(50)
|
||||
})
|
||||
|
||||
it('handlePageChange 限制页码范围', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
|
||||
// 超出范围的页码被限制
|
||||
handlePageChange(999)
|
||||
expect(pagination.page).toBe(5) // 限制在 pages=5
|
||||
|
||||
handlePageChange(0)
|
||||
expect(pagination.page).toBe(1) // 最小为 1
|
||||
})
|
||||
})
|
||||
|
||||
// --- 搜索防抖 ---
|
||||
|
||||
describe('搜索防抖', () => {
|
||||
it('debouncedReload 在 300ms 内多次调用只执行一次', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { debouncedReload } = useTableLoader({ fetchFn })
|
||||
|
||||
// 快速连续调用
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
|
||||
// 还没到 300ms,不应调用 fetchFn
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
|
||||
// 推进 300ms
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
// 等待异步完成
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reload 重置到第 1 页', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { reload, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
pagination.page = 3
|
||||
|
||||
await reload()
|
||||
|
||||
expect(pagination.page).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 请求取消 ---
|
||||
|
||||
describe('请求取消', () => {
|
||||
it('新请求取消前一个未完成的请求', async () => {
|
||||
let callCount = 0
|
||||
const fetchFn = vi.fn((_page, _size, _params, options) => {
|
||||
callCount++
|
||||
const currentCall = callCount
|
||||
return new Promise((resolve, reject) => {
|
||||
// 模拟监听 abort
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
reject({ name: 'CanceledError', code: 'ERR_CANCELED' })
|
||||
})
|
||||
}
|
||||
// 异步解决
|
||||
setTimeout(() => {
|
||||
resolve({ items: [{ id: currentCall }], total: 1, pages: 1 })
|
||||
}, 1000)
|
||||
})
|
||||
})
|
||||
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
// 第一次加载
|
||||
const p1 = load()
|
||||
// 第二次加载(应取消第一次)
|
||||
const p2 = load()
|
||||
|
||||
// 推进时间让第二次完成
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// 等待两个 Promise settle
|
||||
await Promise.allSettled([p1, p2])
|
||||
|
||||
// 第二次请求的结果生效
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 错误处理 ---
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('非取消错误会被抛出', async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error('Server error'))
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
await expect(load()).rejects.toThrow('Server error')
|
||||
})
|
||||
|
||||
it('取消错误被静默处理', async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue({ name: 'CanceledError', code: 'ERR_CANCELED' })
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
// 不应抛出
|
||||
await load()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
export type AddMethod = 'oauth' | 'setup-token'
|
||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token'
|
||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'session_token'
|
||||
|
||||
export interface OAuthState {
|
||||
authUrl: string
|
||||
|
||||
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { getCurrentInstance, onUnmounted } from 'vue'
|
||||
|
||||
export interface KeyedDebouncedSearchContext {
|
||||
key: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
interface UseKeyedDebouncedSearchOptions<T> {
|
||||
delay?: number
|
||||
search: (keyword: string, context: KeyedDebouncedSearchContext) => Promise<T>
|
||||
onSuccess: (key: string, result: T) => void
|
||||
onError?: (key: string, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 多实例隔离的防抖搜索:每个 key 有独立的防抖、请求取消与过期响应保护。
|
||||
*/
|
||||
export function useKeyedDebouncedSearch<T>(options: UseKeyedDebouncedSearchOptions<T>) {
|
||||
const delay = options.delay ?? 300
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const versions = new Map<string, number>()
|
||||
|
||||
const clearKey = (key: string) => {
|
||||
const timer = timers.get(key)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const controller = controllers.get(key)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
versions.delete(key)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const allKeys = new Set<string>([
|
||||
...timers.keys(),
|
||||
...controllers.keys(),
|
||||
...versions.keys()
|
||||
])
|
||||
|
||||
allKeys.forEach((key) => clearKey(key))
|
||||
}
|
||||
|
||||
const trigger = (key: string, keyword: string) => {
|
||||
const nextVersion = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, nextVersion)
|
||||
|
||||
const existingTimer = timers.get(key)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const inFlight = controllers.get(key)
|
||||
if (inFlight) {
|
||||
inFlight.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(key)
|
||||
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
const requestVersion = versions.get(key)
|
||||
|
||||
try {
|
||||
const result = await options.search(keyword, { key, signal: controller.signal })
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onSuccess(key, result)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onError?.(key, error)
|
||||
} finally {
|
||||
if (controllers.get(key) === controller) {
|
||||
controllers.delete(key)
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
|
||||
timers.set(key, timer)
|
||||
}
|
||||
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
clearAll()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
clearKey,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const openaiModels = [
|
||||
'o4-mini',
|
||||
// GPT-5 系列(同步后端定价文件)
|
||||
'gpt-5', 'gpt-5-2025-08-07', 'gpt-5-chat', 'gpt-5-chat-latest',
|
||||
'gpt-5-codex', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
|
||||
'gpt-5-codex', 'gpt-5.3-codex-spark', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
|
||||
'gpt-5-mini', 'gpt-5-mini-2025-08-07',
|
||||
'gpt-5-nano', 'gpt-5-nano-2025-08-07',
|
||||
// GPT-5.1 系列
|
||||
@@ -54,6 +54,22 @@ const geminiModels = [
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
|
||||
// Sora
|
||||
const soraModels = [
|
||||
'gpt-image', 'gpt-image-landscape', 'gpt-image-portrait',
|
||||
'sora2-landscape-10s', 'sora2-portrait-10s',
|
||||
'sora2-landscape-15s', 'sora2-portrait-15s',
|
||||
'sora2-landscape-25s', 'sora2-portrait-25s',
|
||||
'sora2pro-landscape-10s', 'sora2pro-portrait-10s',
|
||||
'sora2pro-landscape-15s', 'sora2pro-portrait-15s',
|
||||
'sora2pro-landscape-25s', 'sora2pro-portrait-25s',
|
||||
'sora2pro-hd-landscape-10s', 'sora2pro-hd-portrait-10s',
|
||||
'sora2pro-hd-landscape-15s', 'sora2pro-hd-portrait-15s',
|
||||
'prompt-enhance-short-10s', 'prompt-enhance-short-15s', 'prompt-enhance-short-20s',
|
||||
'prompt-enhance-medium-10s', 'prompt-enhance-medium-15s', 'prompt-enhance-medium-20s',
|
||||
'prompt-enhance-long-10s', 'prompt-enhance-long-15s', 'prompt-enhance-long-20s'
|
||||
]
|
||||
|
||||
// Antigravity 官方支持的模型(精确匹配)
|
||||
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
|
||||
const antigravityModels = [
|
||||
@@ -207,6 +223,7 @@ const allModelsList: string[] = [
|
||||
...openaiModels,
|
||||
...claudeModels,
|
||||
...geminiModels,
|
||||
...soraModels,
|
||||
...zhipuModels,
|
||||
...qwenModels,
|
||||
...deepseekModels,
|
||||
@@ -249,11 +266,14 @@ const openaiPresetMappings = [
|
||||
{ label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: 'GPT-5.3 Codex Spark', from: 'gpt-5.3-codex-spark', to: 'gpt-5.3-codex-spark', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
{ label: 'GPT-5.1', from: 'gpt-5.1', to: 'gpt-5.1', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
|
||||
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }
|
||||
]
|
||||
|
||||
const soraPresetMappings: { label: string; from: string; to: string; color: string }[] = []
|
||||
|
||||
const geminiPresetMappings = [
|
||||
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
@@ -320,6 +340,7 @@ export function getModelsByPlatform(platform: string): string[] {
|
||||
case 'anthropic':
|
||||
case 'claude': return claudeModels
|
||||
case 'gemini': return geminiModels
|
||||
case 'sora': return soraModels
|
||||
case 'antigravity': return antigravityModels
|
||||
case 'zhipu': return zhipuModels
|
||||
case 'qwen': return qwenModels
|
||||
@@ -344,6 +365,7 @@ export function getModelsByPlatform(platform: string): string[] {
|
||||
export function getPresetMappingsByPlatform(platform: string) {
|
||||
if (platform === 'openai') return openaiPresetMappings
|
||||
if (platform === 'gemini') return geminiPresetMappings
|
||||
if (platform === 'sora') return soraPresetMappings
|
||||
if (platform === 'antigravity') return antigravityPresetMappings
|
||||
return anthropicPresetMappings
|
||||
}
|
||||
|
||||
@@ -19,12 +19,21 @@ export interface OpenAITokenInfo {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useOpenAIOAuth() {
|
||||
export type OpenAIOAuthPlatform = 'openai' | 'sora'
|
||||
|
||||
interface UseOpenAIOAuthOptions {
|
||||
platform?: OpenAIOAuthPlatform
|
||||
}
|
||||
|
||||
export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
|
||||
const appStore = useAppStore()
|
||||
const oauthPlatform = options?.platform ?? 'openai'
|
||||
const endpointPrefix = oauthPlatform === 'sora' ? '/admin/sora' : '/admin/openai'
|
||||
|
||||
// State
|
||||
const authUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const oauthState = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
@@ -32,6 +41,7 @@ export function useOpenAIOAuth() {
|
||||
const resetState = () => {
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
oauthState.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
@@ -44,6 +54,7 @@ export function useOpenAIOAuth() {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
oauthState.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
@@ -56,11 +67,17 @@ export function useOpenAIOAuth() {
|
||||
}
|
||||
|
||||
const response = await adminAPI.accounts.generateAuthUrl(
|
||||
'/admin/openai/generate-auth-url',
|
||||
`${endpointPrefix}/generate-auth-url`,
|
||||
payload
|
||||
)
|
||||
authUrl.value = response.auth_url
|
||||
sessionId.value = response.session_id
|
||||
try {
|
||||
const parsed = new URL(response.auth_url)
|
||||
oauthState.value = parsed.searchParams.get('state') || ''
|
||||
} catch {
|
||||
oauthState.value = ''
|
||||
}
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to generate OpenAI auth URL'
|
||||
@@ -75,10 +92,11 @@ export function useOpenAIOAuth() {
|
||||
const exchangeAuthCode = async (
|
||||
code: string,
|
||||
currentSessionId: string,
|
||||
state: string,
|
||||
proxyId?: number | null
|
||||
): Promise<OpenAITokenInfo | null> => {
|
||||
if (!code.trim() || !currentSessionId) {
|
||||
error.value = 'Missing auth code or session ID'
|
||||
if (!code.trim() || !currentSessionId || !state.trim()) {
|
||||
error.value = 'Missing auth code, session ID, or state'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -86,15 +104,16 @@ export function useOpenAIOAuth() {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: { session_id: string; code: string; proxy_id?: number } = {
|
||||
const payload: { session_id: string; code: string; state: string; proxy_id?: number } = {
|
||||
session_id: currentSessionId,
|
||||
code: code.trim()
|
||||
code: code.trim(),
|
||||
state: state.trim()
|
||||
}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode('/admin/openai/exchange-code', payload)
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(`${endpointPrefix}/exchange-code`, payload)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to exchange OpenAI auth code'
|
||||
@@ -120,7 +139,11 @@ export function useOpenAIOAuth() {
|
||||
|
||||
try {
|
||||
// Use dedicated refresh-token endpoint
|
||||
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(refreshToken.trim(), proxyId)
|
||||
const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(
|
||||
refreshToken.trim(),
|
||||
proxyId,
|
||||
`${endpointPrefix}/refresh-token`
|
||||
)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to validate refresh token'
|
||||
@@ -131,6 +154,33 @@ export function useOpenAIOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Sora session token and get access token
|
||||
const validateSessionToken = async (
|
||||
sessionToken: string,
|
||||
proxyId?: number | null
|
||||
): Promise<OpenAITokenInfo | null> => {
|
||||
if (!sessionToken.trim()) {
|
||||
error.value = 'Missing session token'
|
||||
return null
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const tokenInfo = await adminAPI.accounts.validateSoraSessionToken(
|
||||
sessionToken.trim(),
|
||||
proxyId,
|
||||
`${endpointPrefix}/st2at`
|
||||
)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to validate session token'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Build credentials for OpenAI OAuth account
|
||||
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
|
||||
const creds: Record<string, unknown> = {
|
||||
@@ -172,6 +222,7 @@ export function useOpenAIOAuth() {
|
||||
// State
|
||||
authUrl,
|
||||
sessionId,
|
||||
oauthState,
|
||||
loading,
|
||||
error,
|
||||
// Methods
|
||||
@@ -179,6 +230,7 @@ export function useOpenAIOAuth() {
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
validateRefreshToken,
|
||||
validateSessionToken,
|
||||
buildCredentials,
|
||||
buildExtraInfo
|
||||
}
|
||||
|
||||
@@ -1,53 +1,83 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en'
|
||||
import zh from './locales/zh'
|
||||
|
||||
type LocaleCode = 'en' | 'zh'
|
||||
|
||||
type LocaleMessages = Record<string, any>
|
||||
|
||||
const LOCALE_KEY = 'sub2api_locale'
|
||||
const DEFAULT_LOCALE: LocaleCode = 'en'
|
||||
|
||||
function getDefaultLocale(): string {
|
||||
// Check localStorage first
|
||||
const localeLoaders: Record<LocaleCode, () => Promise<{ default: LocaleMessages }>> = {
|
||||
en: () => import('./locales/en'),
|
||||
zh: () => import('./locales/zh')
|
||||
}
|
||||
|
||||
function isLocaleCode(value: string): value is LocaleCode {
|
||||
return value === 'en' || value === 'zh'
|
||||
}
|
||||
|
||||
function getDefaultLocale(): LocaleCode {
|
||||
const saved = localStorage.getItem(LOCALE_KEY)
|
||||
if (saved && ['en', 'zh'].includes(saved)) {
|
||||
if (saved && isLocaleCode(saved)) {
|
||||
return saved
|
||||
}
|
||||
|
||||
// Check browser language
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
if (browserLang.startsWith('zh')) {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
return 'en'
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
zh
|
||||
},
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages: {},
|
||||
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML)
|
||||
// 这些内容是内部定义的,不存在 XSS 风险
|
||||
warnHtmlMessage: false
|
||||
})
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
if (['en', 'zh'].includes(locale)) {
|
||||
i18n.global.locale.value = locale as 'en' | 'zh'
|
||||
localStorage.setItem(LOCALE_KEY, locale)
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
const loadedLocales = new Set<LocaleCode>()
|
||||
|
||||
export async function loadLocaleMessages(locale: LocaleCode): Promise<void> {
|
||||
if (loadedLocales.has(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
const loader = localeLoaders[locale]
|
||||
const module = await loader()
|
||||
i18n.global.setLocaleMessage(locale, module.default)
|
||||
loadedLocales.add(locale)
|
||||
}
|
||||
|
||||
export function getLocale(): string {
|
||||
return i18n.global.locale.value
|
||||
export async function initI18n(): Promise<void> {
|
||||
const current = getLocale()
|
||||
await loadLocaleMessages(current)
|
||||
document.documentElement.setAttribute('lang', current)
|
||||
}
|
||||
|
||||
export async function setLocale(locale: string): Promise<void> {
|
||||
if (!isLocaleCode(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadLocaleMessages(locale)
|
||||
i18n.global.locale.value = locale
|
||||
localStorage.setItem(LOCALE_KEY, locale)
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
}
|
||||
|
||||
export function getLocale(): LocaleCode {
|
||||
const current = i18n.global.locale.value
|
||||
return isLocaleCode(current) ? current : DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export const availableLocales = [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'zh', name: '中文', flag: '🇨🇳' }
|
||||
]
|
||||
] as const
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -478,6 +478,7 @@ export default {
|
||||
today: 'Today',
|
||||
total: 'Total',
|
||||
quota: 'Quota',
|
||||
lastUsedAt: 'Last Used',
|
||||
useKey: 'Use Key',
|
||||
useKeyModal: {
|
||||
title: 'Use API Key',
|
||||
@@ -1108,7 +1109,8 @@ export default {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||
@@ -1133,6 +1135,14 @@ export default {
|
||||
title: 'Image Generation Pricing',
|
||||
description: 'Configure pricing for gemini-3-pro-image model. Leave empty to use default prices.'
|
||||
},
|
||||
soraPricing: {
|
||||
title: 'Sora Per-Request Pricing',
|
||||
description: 'Configure per-request pricing for Sora image/video generation. Leave empty to disable billing.',
|
||||
image360: 'Image 360px ($)',
|
||||
image540: 'Image 540px ($)',
|
||||
video: 'Video (standard) ($)',
|
||||
videoHd: 'Video (Pro-HD) ($)'
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code Client Restriction',
|
||||
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
|
||||
@@ -1275,6 +1285,8 @@ export default {
|
||||
refreshInterval15s: '15 seconds',
|
||||
refreshInterval30s: '30 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
listPendingSyncHint: 'List changes are pending sync. Click sync to load latest rows.',
|
||||
listPendingSyncAction: 'Sync now',
|
||||
syncFromCrs: 'Sync from CRS',
|
||||
dataExport: 'Export',
|
||||
dataExportSelected: 'Export Selected',
|
||||
@@ -1355,7 +1367,8 @@ export default {
|
||||
claude: 'Claude',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -1525,7 +1538,21 @@ export default {
|
||||
// OpenAI specific hints
|
||||
openai: {
|
||||
baseUrlHint: 'Leave default for official OpenAI API',
|
||||
apiKeyHint: 'Your OpenAI API Key'
|
||||
apiKeyHint: 'Your OpenAI API Key',
|
||||
oauthPassthrough: 'Auto passthrough (auth only)',
|
||||
oauthPassthroughDesc:
|
||||
'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.',
|
||||
codexCLIOnly: 'Codex official clients only',
|
||||
codexCLIOnlyDesc:
|
||||
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
|
||||
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
|
||||
enableSora: 'Enable Sora simultaneously',
|
||||
enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: 'Auto passthrough (auth only)',
|
||||
apiKeyPassthroughDesc:
|
||||
'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.'
|
||||
},
|
||||
modelRestriction: 'Model Restriction (Optional)',
|
||||
modelWhitelist: 'Model Whitelist',
|
||||
@@ -1535,6 +1562,9 @@ export default {
|
||||
'Map request models to actual models. Left is the requested model, right is the actual model sent to API.',
|
||||
selectedModels: 'Selected {count} model(s)',
|
||||
supportsAllModels: '(supports all models)',
|
||||
soraModelsLoadFailed: 'Failed to load Sora models, fallback to default list',
|
||||
soraModelsLoading: 'Loading Sora models...',
|
||||
soraModelsRetry: 'Load failed, click to retry',
|
||||
requestModel: 'Request model',
|
||||
actualModel: 'Actual model',
|
||||
addMapping: 'Add Mapping',
|
||||
@@ -1625,6 +1655,8 @@ export default {
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
accountCreated: 'Account created successfully',
|
||||
soraAccountCreated: 'Sora account created simultaneously',
|
||||
soraAccountFailed: 'Failed to create Sora account, please add manually later',
|
||||
accountUpdated: 'Account updated successfully',
|
||||
failedToCreate: 'Failed to create account',
|
||||
failedToUpdate: 'Failed to update account',
|
||||
@@ -1716,9 +1748,13 @@ export default {
|
||||
refreshTokenAuth: 'Manual RT Input',
|
||||
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
|
||||
sessionTokenAuth: 'Manual ST Input',
|
||||
sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
|
||||
validating: 'Validating...',
|
||||
validateAndCreate: 'Validate & Create Account',
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token'
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token',
|
||||
pleaseEnterSessionToken: 'Please enter Session Token'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
@@ -1939,6 +1975,7 @@ export default {
|
||||
reAuthorizeAccount: 'Re-Authorize Account',
|
||||
claudeCodeAccount: 'Claude Code Account',
|
||||
openaiAccount: 'OpenAI Account',
|
||||
soraAccount: 'Sora Account',
|
||||
geminiAccount: 'Gemini Account',
|
||||
antigravityAccount: 'Antigravity Account',
|
||||
inputMethod: 'Input Method',
|
||||
@@ -1964,6 +2001,10 @@ export default {
|
||||
selectTestModel: 'Select Test Model',
|
||||
testModel: 'Test model',
|
||||
testPrompt: 'Prompt: "hi"',
|
||||
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
|
||||
soraTestTarget: 'Target: Sora account capability',
|
||||
soraTestMode: 'Mode: Connectivity + Capability checks',
|
||||
soraTestingFlow: 'Running Sora connectivity and capability checks...',
|
||||
// Stats Modal
|
||||
viewStats: 'View Stats',
|
||||
usageStatistics: 'Usage Statistics',
|
||||
@@ -2070,6 +2111,8 @@ export default {
|
||||
actions: 'Actions'
|
||||
},
|
||||
testConnection: 'Test Connection',
|
||||
qualityCheck: 'Quality Check',
|
||||
batchQualityCheck: 'Batch Quality Check',
|
||||
batchTest: 'Test All Proxies',
|
||||
testFailed: 'Failed',
|
||||
latencyFailed: 'Connection failed',
|
||||
@@ -2130,6 +2173,29 @@ export default {
|
||||
proxyWorking: 'Proxy is working!',
|
||||
proxyWorkingWithLatency: 'Proxy is working! Latency: {latency}ms',
|
||||
proxyTestFailed: 'Proxy test failed',
|
||||
qualityCheckDone: 'Quality check completed: score {score} ({grade})',
|
||||
qualityCheckFailed: 'Failed to run proxy quality check',
|
||||
batchQualityDone:
|
||||
'Batch quality check completed for {count} proxies: healthy {healthy}, warn {warn}, challenge {challenge}, abnormal {failed}',
|
||||
batchQualityFailed: 'Batch quality check failed',
|
||||
batchQualityEmpty: 'No proxies available for quality check',
|
||||
qualityReportTitle: 'Proxy Quality Report',
|
||||
qualityGrade: 'Grade {grade}',
|
||||
qualityExitIP: 'Exit IP',
|
||||
qualityCountry: 'Exit Region',
|
||||
qualityBaseLatency: 'Base Latency',
|
||||
qualityCheckedAt: 'Checked At',
|
||||
qualityTableTarget: 'Target',
|
||||
qualityTableStatus: 'Status',
|
||||
qualityTableLatency: 'Latency',
|
||||
qualityTableMessage: 'Message',
|
||||
qualityInline: 'Quality {grade}/{score}',
|
||||
qualityStatusHealthy: 'Healthy',
|
||||
qualityStatusPass: 'Pass',
|
||||
qualityStatusWarn: 'Warn',
|
||||
qualityStatusFail: 'Fail',
|
||||
qualityStatusChallenge: 'Challenge',
|
||||
qualityTargetBase: 'Base Connectivity',
|
||||
failedToLoad: 'Failed to load proxies',
|
||||
failedToCreate: 'Failed to create proxy',
|
||||
failedToUpdate: 'Failed to update proxy',
|
||||
@@ -2507,11 +2573,33 @@ export default {
|
||||
'5m': 'Last 5 minutes',
|
||||
'30m': 'Last 30 minutes',
|
||||
'1h': 'Last 1 hour',
|
||||
'1d': 'Last 1 day',
|
||||
'15d': 'Last 15 days',
|
||||
'6h': 'Last 6 hours',
|
||||
'24h': 'Last 24 hours',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days'
|
||||
},
|
||||
openaiTokenStats: {
|
||||
title: 'OpenAI Token Request Stats',
|
||||
viewModeTopN: 'TopN',
|
||||
viewModePagination: 'Pagination',
|
||||
prevPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
pageInfo: 'Page {page}/{total}',
|
||||
totalModels: 'Total models: {total}',
|
||||
failedToLoad: 'Failed to load OpenAI token stats',
|
||||
empty: 'No OpenAI token stats for the current filters',
|
||||
table: {
|
||||
model: 'Model',
|
||||
requestCount: 'Requests',
|
||||
avgTokensPerSec: 'Avg Tokens/sec',
|
||||
avgFirstTokenMs: 'Avg First Token Latency (ms)',
|
||||
totalOutputTokens: 'Total Output Tokens',
|
||||
avgDurationMs: 'Avg Duration (ms)',
|
||||
requestsWithFirstToken: 'Requests With First Token'
|
||||
}
|
||||
},
|
||||
fullscreen: {
|
||||
enter: 'Enter Fullscreen'
|
||||
},
|
||||
|
||||
@@ -479,6 +479,7 @@ export default {
|
||||
today: '今日',
|
||||
total: '累计',
|
||||
quota: '额度',
|
||||
lastUsedAt: '上次使用时间',
|
||||
useKey: '使用密钥',
|
||||
useKeyModal: {
|
||||
title: '使用 API 密钥',
|
||||
@@ -1166,7 +1167,8 @@ export default {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
saving: '保存中...',
|
||||
noGroups: '暂无分组',
|
||||
@@ -1220,6 +1222,14 @@ export default {
|
||||
title: '图片生成计费',
|
||||
description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格'
|
||||
},
|
||||
soraPricing: {
|
||||
title: 'Sora 按次计费',
|
||||
description: '配置 Sora 图片/视频按次收费价格,留空则默认不计费',
|
||||
image360: '图片 360px ($)',
|
||||
image540: '图片 540px ($)',
|
||||
video: '视频(标准)($)',
|
||||
videoHd: '视频(Pro-HD)($)'
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code 客户端限制',
|
||||
tooltip:
|
||||
@@ -1363,6 +1373,8 @@ export default {
|
||||
refreshInterval15s: '15 秒',
|
||||
refreshInterval30s: '30 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
listPendingSyncHint: '列表存在待同步变更,点击同步可补齐最新数据。',
|
||||
listPendingSyncAction: '立即同步',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
dataExport: '导出',
|
||||
dataExportSelected: '导出选中',
|
||||
@@ -1489,7 +1501,8 @@ export default {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
antigravity: 'Antigravity',
|
||||
sora: 'Sora'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -1674,7 +1687,20 @@ export default {
|
||||
// OpenAI specific hints
|
||||
openai: {
|
||||
baseUrlHint: '留空使用官方 OpenAI API',
|
||||
apiKeyHint: '您的 OpenAI API Key'
|
||||
apiKeyHint: '您的 OpenAI API Key',
|
||||
oauthPassthrough: '自动透传(仅替换认证)',
|
||||
oauthPassthroughDesc:
|
||||
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
|
||||
codexCLIOnly: '仅允许 Codex 官方客户端',
|
||||
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
|
||||
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
|
||||
enableSora: '同时启用 Sora',
|
||||
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
|
||||
},
|
||||
anthropic: {
|
||||
apiKeyPassthrough: '自动透传(仅替换认证)',
|
||||
apiKeyPassthroughDesc:
|
||||
'仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。'
|
||||
},
|
||||
modelRestriction: '模型限制(可选)',
|
||||
modelWhitelist: '模型白名单',
|
||||
@@ -1683,6 +1709,9 @@ export default {
|
||||
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
|
||||
selectedModels: '已选择 {count} 个模型',
|
||||
supportsAllModels: '(支持所有模型)',
|
||||
soraModelsLoadFailed: '加载 Sora 模型列表失败,已回退到默认列表',
|
||||
soraModelsLoading: '正在加载 Sora 模型...',
|
||||
soraModelsRetry: '加载失败,点击重试',
|
||||
requestModel: '请求模型',
|
||||
actualModel: '实际模型',
|
||||
addMapping: '添加映射',
|
||||
@@ -1771,6 +1800,8 @@ export default {
|
||||
creating: '创建中...',
|
||||
updating: '更新中...',
|
||||
accountCreated: '账号创建成功',
|
||||
soraAccountCreated: 'Sora 账号已同时创建',
|
||||
soraAccountFailed: 'Sora 账号创建失败,请稍后手动添加',
|
||||
accountUpdated: '账号更新成功',
|
||||
failedToCreate: '创建账号失败',
|
||||
failedToUpdate: '更新账号失败',
|
||||
@@ -1856,9 +1887,13 @@ export default {
|
||||
refreshTokenAuth: '手动输入 RT',
|
||||
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
|
||||
sessionTokenAuth: '手动输入 ST',
|
||||
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
|
||||
validating: '验证中...',
|
||||
validateAndCreate: '验证并创建账号',
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token'
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||
pleaseEnterSessionToken: '请输入 Session Token'
|
||||
},
|
||||
// Gemini specific
|
||||
gemini: {
|
||||
@@ -2074,6 +2109,7 @@ export default {
|
||||
reAuthorizeAccount: '重新授权账号',
|
||||
claudeCodeAccount: 'Claude Code 账号',
|
||||
openaiAccount: 'OpenAI 账号',
|
||||
soraAccount: 'Sora 账号',
|
||||
geminiAccount: 'Gemini 账号',
|
||||
antigravityAccount: 'Antigravity 账号',
|
||||
inputMethod: '输入方式',
|
||||
@@ -2097,6 +2133,10 @@ export default {
|
||||
selectTestModel: '选择测试模型',
|
||||
testModel: '测试模型',
|
||||
testPrompt: '提示词:"hi"',
|
||||
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
|
||||
soraTestTarget: '检测目标:Sora 账号能力',
|
||||
soraTestMode: '模式:连通性 + 能力探测',
|
||||
soraTestingFlow: '执行 Sora 连通性与能力检测...',
|
||||
// Stats Modal
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
@@ -2214,6 +2254,8 @@ export default {
|
||||
noProxiesYet: '暂无代理',
|
||||
createFirstProxy: '添加您的第一个代理以开始使用。',
|
||||
testConnection: '测试连接',
|
||||
qualityCheck: '质量检测',
|
||||
batchQualityCheck: '批量质量检测',
|
||||
batchTest: '批量测试',
|
||||
testFailed: '失败',
|
||||
latencyFailed: '链接失败',
|
||||
@@ -2261,6 +2303,28 @@ export default {
|
||||
proxyWorking: '代理连接正常',
|
||||
proxyWorkingWithLatency: '代理连接正常,延迟 {latency}ms',
|
||||
proxyTestFailed: '代理测试失败',
|
||||
qualityCheckDone: '质量检测完成:评分 {score}({grade})',
|
||||
qualityCheckFailed: '代理质量检测失败',
|
||||
batchQualityDone: '批量质量检测完成,共检测 {count} 个;优质 {healthy} 个,告警 {warn} 个,挑战 {challenge} 个,异常 {failed} 个',
|
||||
batchQualityFailed: '批量质量检测失败',
|
||||
batchQualityEmpty: '暂无可检测质量的代理',
|
||||
qualityReportTitle: '代理质量检测报告',
|
||||
qualityGrade: '等级 {grade}',
|
||||
qualityExitIP: '出口 IP',
|
||||
qualityCountry: '出口地区',
|
||||
qualityBaseLatency: '基础延迟',
|
||||
qualityCheckedAt: '检测时间',
|
||||
qualityTableTarget: '检测项',
|
||||
qualityTableStatus: '状态',
|
||||
qualityTableLatency: '延迟',
|
||||
qualityTableMessage: '说明',
|
||||
qualityInline: '质量 {grade}/{score}',
|
||||
qualityStatusHealthy: '优质',
|
||||
qualityStatusPass: '通过',
|
||||
qualityStatusWarn: '告警',
|
||||
qualityStatusFail: '失败',
|
||||
qualityStatusChallenge: '挑战',
|
||||
qualityTargetBase: '基础连通性',
|
||||
proxyCreatedSuccess: '代理添加成功',
|
||||
proxyUpdatedSuccess: '代理更新成功',
|
||||
proxyDeletedSuccess: '代理删除成功',
|
||||
@@ -2675,12 +2739,34 @@ export default {
|
||||
'5m': '近5分钟',
|
||||
'30m': '近30分钟',
|
||||
'1h': '近1小时',
|
||||
'1d': '近1天',
|
||||
'15d': '近15天',
|
||||
'6h': '近6小时',
|
||||
'24h': '近24小时',
|
||||
'7d': '近7天',
|
||||
'30d': '近30天',
|
||||
custom: '自定义'
|
||||
},
|
||||
openaiTokenStats: {
|
||||
title: 'OpenAI Token 请求统计',
|
||||
viewModeTopN: 'TopN',
|
||||
viewModePagination: '分页',
|
||||
prevPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
pageInfo: '第 {page}/{total} 页',
|
||||
totalModels: '模型总数:{total}',
|
||||
failedToLoad: '加载 OpenAI Token 统计失败',
|
||||
empty: '当前筛选条件下暂无 OpenAI Token 请求统计数据',
|
||||
table: {
|
||||
model: '模型',
|
||||
requestCount: '请求数',
|
||||
avgTokensPerSec: '平均 Tokens/秒',
|
||||
avgFirstTokenMs: '平均首 Token 延迟(ms)',
|
||||
totalOutputTokens: '输出 Token 总数',
|
||||
avgDurationMs: '平均时长(ms)',
|
||||
requestsWithFirstToken: '首 Token 样本数'
|
||||
}
|
||||
},
|
||||
customTimeRange: {
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间'
|
||||
|
||||
@@ -2,28 +2,33 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import i18n, { initI18n } from './i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
async function bootstrap() {
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
import { useAppStore } from '@/stores/app'
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
}
|
||||
|
||||
await initI18n()
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
|
||||
router.isReady().then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
bootstrap()
|
||||
|
||||
267
frontend/src/router/__tests__/guards.spec.ts
Normal file
267
frontend/src/router/__tests__/guards.spec.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock 导航加载状态
|
||||
vi.mock('@/composables/useNavigationLoading', () => {
|
||||
const mockStart = vi.fn()
|
||||
const mockEnd = vi.fn()
|
||||
return {
|
||||
useNavigationLoadingState: () => ({
|
||||
startNavigation: mockStart,
|
||||
endNavigation: mockEnd,
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
useNavigationLoading: () => ({
|
||||
startNavigation: mockStart,
|
||||
endNavigation: mockEnd,
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock 路由预加载
|
||||
vi.mock('@/composables/useRoutePrefetch', () => ({
|
||||
useRoutePrefetch: () => ({
|
||||
triggerPrefetch: vi.fn(),
|
||||
cancelPendingPrefetch: vi.fn(),
|
||||
resetPrefetchState: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API 相关模块
|
||||
vi.mock('@/api', () => ({
|
||||
authAPI: {
|
||||
getCurrentUser: vi.fn().mockResolvedValue({ data: {} }),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
isTotp2FARequired: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
// 用于测试的 auth 状态
|
||||
interface MockAuthState {
|
||||
isAuthenticated: boolean
|
||||
isAdmin: boolean
|
||||
isSimpleMode: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 router/index.ts 中 beforeEach 守卫的核心逻辑提取为可测试的函数
|
||||
*/
|
||||
function simulateGuard(
|
||||
toPath: string,
|
||||
toMeta: Record<string, any>,
|
||||
authState: MockAuthState
|
||||
): string | null {
|
||||
const requiresAuth = toMeta.requiresAuth !== false
|
||||
const requiresAdmin = toMeta.requiresAdmin === true
|
||||
|
||||
// 不需要认证的路由
|
||||
if (!requiresAuth) {
|
||||
if (
|
||||
authState.isAuthenticated &&
|
||||
(toPath === '/login' || toPath === '/register')
|
||||
) {
|
||||
return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
}
|
||||
return null // 允许通过
|
||||
}
|
||||
|
||||
// 需要认证但未登录
|
||||
if (!authState.isAuthenticated) {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
// 需要管理员但不是管理员
|
||||
if (requiresAdmin && !authState.isAdmin) {
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
// 简易模式限制
|
||||
if (authState.isSimpleMode) {
|
||||
const restrictedPaths = [
|
||||
'/admin/groups',
|
||||
'/admin/subscriptions',
|
||||
'/admin/redeem',
|
||||
'/subscriptions',
|
||||
'/redeem',
|
||||
]
|
||||
if (restrictedPaths.some((path) => toPath.startsWith(path))) {
|
||||
return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
return null // 允许通过
|
||||
}
|
||||
|
||||
describe('路由守卫逻辑', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
// --- 未认证用户 ---
|
||||
|
||||
describe('未认证用户', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: false,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
}
|
||||
|
||||
it('访问需要认证的页面重定向到 /login', () => {
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBe('/login')
|
||||
})
|
||||
|
||||
it('访问管理页面重定向到 /login', () => {
|
||||
const redirect = simulateGuard('/admin/dashboard', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/login')
|
||||
})
|
||||
|
||||
it('访问公开页面允许通过', () => {
|
||||
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('访问 /home 公开页面允许通过', () => {
|
||||
const redirect = simulateGuard('/home', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- 已认证普通用户 ---
|
||||
|
||||
describe('已认证普通用户', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
}
|
||||
|
||||
it('访问 /login 重定向到 /dashboard', () => {
|
||||
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('访问 /register 重定向到 /dashboard', () => {
|
||||
const redirect = simulateGuard('/register', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('访问 /dashboard 允许通过', () => {
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('访问管理页面被拒绝,重定向到 /dashboard', () => {
|
||||
const redirect = simulateGuard('/admin/dashboard', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('访问 /admin/users 被拒绝', () => {
|
||||
const redirect = simulateGuard('/admin/users', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
})
|
||||
|
||||
// --- 已认证管理员 ---
|
||||
|
||||
describe('已认证管理员', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: true,
|
||||
isSimpleMode: false,
|
||||
}
|
||||
|
||||
it('访问 /login 重定向到 /admin/dashboard', () => {
|
||||
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBe('/admin/dashboard')
|
||||
})
|
||||
|
||||
it('访问管理页面允许通过', () => {
|
||||
const redirect = simulateGuard('/admin/dashboard', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('访问用户页面允许通过', () => {
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- 简易模式 ---
|
||||
|
||||
describe('简易模式受限路由', () => {
|
||||
it('普通用户简易模式访问 /subscriptions 重定向到 /dashboard', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/subscriptions', {}, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('普通用户简易模式访问 /redeem 重定向到 /dashboard', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/redeem', {}, authState)
|
||||
expect(redirect).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('管理员简易模式访问 /admin/groups 重定向到 /admin/dashboard', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: true,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/admin/groups', { requiresAdmin: true }, authState)
|
||||
expect(redirect).toBe('/admin/dashboard')
|
||||
})
|
||||
|
||||
it('管理员简易模式访问 /admin/subscriptions 重定向', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: true,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard(
|
||||
'/admin/subscriptions',
|
||||
{ requiresAdmin: true },
|
||||
authState
|
||||
)
|
||||
expect(redirect).toBe('/admin/dashboard')
|
||||
})
|
||||
|
||||
it('简易模式下非受限页面正常访问', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/dashboard', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('简易模式下 /keys 正常访问', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: true,
|
||||
}
|
||||
const redirect = simulateGuard('/keys', {}, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
25
frontend/src/router/__tests__/title.spec.ts
Normal file
25
frontend/src/router/__tests__/title.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveDocumentTitle } from '@/router/title'
|
||||
|
||||
describe('resolveDocumentTitle', () => {
|
||||
it('路由存在标题时,使用“路由标题 - 站点名”格式', () => {
|
||||
expect(resolveDocumentTitle('Usage Records', 'My Site')).toBe('Usage Records - My Site')
|
||||
})
|
||||
|
||||
it('路由无标题时,回退到站点名', () => {
|
||||
expect(resolveDocumentTitle(undefined, 'My Site')).toBe('My Site')
|
||||
})
|
||||
|
||||
it('站点名为空时,回退默认站点名', () => {
|
||||
expect(resolveDocumentTitle('Dashboard', '')).toBe('Dashboard - Sub2API')
|
||||
expect(resolveDocumentTitle(undefined, ' ')).toBe('Sub2API')
|
||||
})
|
||||
|
||||
it('站点名变更时仅影响后续路由标题计算', () => {
|
||||
const before = resolveDocumentTitle('Admin Dashboard', 'Alpha')
|
||||
const after = resolveDocumentTitle('Admin Dashboard', 'Beta')
|
||||
|
||||
expect(before).toBe('Admin Dashboard - Alpha')
|
||||
expect(after).toBe('Admin Dashboard - Beta')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
import { resolveDocumentTitle } from './title'
|
||||
|
||||
/**
|
||||
* Route definitions with lazy loading
|
||||
@@ -389,12 +390,7 @@ router.beforeEach((to, _from, next) => {
|
||||
|
||||
// Set page title
|
||||
const appStore = useAppStore()
|
||||
const siteName = appStore.siteName || 'Sub2API'
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - ${siteName}`
|
||||
} else {
|
||||
document.title = siteName
|
||||
}
|
||||
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName)
|
||||
|
||||
// Check if route requires authentication
|
||||
const requiresAuth = to.meta.requiresAuth !== false // Default to true
|
||||
|
||||
12
frontend/src/router/title.ts
Normal file
12
frontend/src/router/title.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 统一生成页面标题,避免多处写入 document.title 产生覆盖冲突。
|
||||
*/
|
||||
export function resolveDocumentTitle(routeTitle: unknown, siteName?: string): string {
|
||||
const normalizedSiteName = typeof siteName === 'string' && siteName.trim() ? siteName.trim() : 'Sub2API'
|
||||
|
||||
if (typeof routeTitle === 'string' && routeTitle.trim()) {
|
||||
return `${routeTitle.trim()} - ${normalizedSiteName}`
|
||||
}
|
||||
|
||||
return normalizedSiteName
|
||||
}
|
||||
295
frontend/src/stores/__tests__/app.spec.ts
Normal file
295
frontend/src/stores/__tests__/app.spec.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
// Mock API 模块
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useAppStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
// 清除 window.__APP_CONFIG__
|
||||
delete (window as any).__APP_CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// --- Toast 消息管理 ---
|
||||
|
||||
describe('Toast 消息管理', () => {
|
||||
it('showSuccess 创建 success 类型 toast', () => {
|
||||
const store = useAppStore()
|
||||
const id = store.showSuccess('操作成功')
|
||||
|
||||
expect(id).toMatch(/^toast-/)
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].type).toBe('success')
|
||||
expect(store.toasts[0].message).toBe('操作成功')
|
||||
})
|
||||
|
||||
it('showError 创建 error 类型 toast', () => {
|
||||
const store = useAppStore()
|
||||
store.showError('出错了')
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].type).toBe('error')
|
||||
expect(store.toasts[0].message).toBe('出错了')
|
||||
})
|
||||
|
||||
it('showWarning 创建 warning 类型 toast', () => {
|
||||
const store = useAppStore()
|
||||
store.showWarning('警告信息')
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].type).toBe('warning')
|
||||
})
|
||||
|
||||
it('showInfo 创建 info 类型 toast', () => {
|
||||
const store = useAppStore()
|
||||
store.showInfo('提示信息')
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].type).toBe('info')
|
||||
})
|
||||
|
||||
it('toast 在指定 duration 后自动消失', () => {
|
||||
const store = useAppStore()
|
||||
store.showSuccess('临时消息', 3000)
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(store.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hideToast 移除指定 toast', () => {
|
||||
const store = useAppStore()
|
||||
const id = store.showSuccess('消息1')
|
||||
store.showError('消息2')
|
||||
|
||||
expect(store.toasts).toHaveLength(2)
|
||||
|
||||
store.hideToast(id)
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].type).toBe('error')
|
||||
})
|
||||
|
||||
it('clearAllToasts 清除所有 toast', () => {
|
||||
const store = useAppStore()
|
||||
store.showSuccess('消息1')
|
||||
store.showError('消息2')
|
||||
store.showWarning('消息3')
|
||||
|
||||
expect(store.toasts).toHaveLength(3)
|
||||
|
||||
store.clearAllToasts()
|
||||
|
||||
expect(store.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hasActiveToasts 正确反映 toast 状态', () => {
|
||||
const store = useAppStore()
|
||||
expect(store.hasActiveToasts).toBe(false)
|
||||
|
||||
store.showSuccess('消息')
|
||||
expect(store.hasActiveToasts).toBe(true)
|
||||
|
||||
store.clearAllToasts()
|
||||
expect(store.hasActiveToasts).toBe(false)
|
||||
})
|
||||
|
||||
it('多个 toast 的 ID 唯一', () => {
|
||||
const store = useAppStore()
|
||||
const id1 = store.showSuccess('消息1')
|
||||
const id2 = store.showSuccess('消息2')
|
||||
const id3 = store.showSuccess('消息3')
|
||||
|
||||
expect(id1).not.toBe(id2)
|
||||
expect(id2).not.toBe(id3)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 侧边栏 ---
|
||||
|
||||
describe('侧边栏管理', () => {
|
||||
it('toggleSidebar 切换折叠状态', () => {
|
||||
const store = useAppStore()
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
|
||||
store.toggleSidebar()
|
||||
expect(store.sidebarCollapsed).toBe(true)
|
||||
|
||||
store.toggleSidebar()
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('setSidebarCollapsed 直接设置状态', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setSidebarCollapsed(true)
|
||||
expect(store.sidebarCollapsed).toBe(true)
|
||||
|
||||
store.setSidebarCollapsed(false)
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleMobileSidebar 切换移动端状态', () => {
|
||||
const store = useAppStore()
|
||||
expect(store.mobileOpen).toBe(false)
|
||||
|
||||
store.toggleMobileSidebar()
|
||||
expect(store.mobileOpen).toBe(true)
|
||||
|
||||
store.toggleMobileSidebar()
|
||||
expect(store.mobileOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Loading 状态 ---
|
||||
|
||||
describe('Loading 状态管理', () => {
|
||||
it('setLoading 管理引用计数', () => {
|
||||
const store = useAppStore()
|
||||
expect(store.loading).toBe(false)
|
||||
|
||||
store.setLoading(true)
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
store.setLoading(true) // 两次 true
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
store.setLoading(false) // 第一次 false,计数还是 1
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
store.setLoading(false) // 第二次 false,计数为 0
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('setLoading(false) 不会使计数为负', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setLoading(false)
|
||||
store.setLoading(false)
|
||||
expect(store.loading).toBe(false)
|
||||
|
||||
store.setLoading(true)
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
store.setLoading(false)
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('withLoading 自动管理 loading 状态', async () => {
|
||||
const store = useAppStore()
|
||||
|
||||
const result = await store.withLoading(async () => {
|
||||
expect(store.loading).toBe(true)
|
||||
return 'done'
|
||||
})
|
||||
|
||||
expect(result).toBe('done')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('withLoading 错误时也恢复 loading 状态', async () => {
|
||||
const store = useAppStore()
|
||||
|
||||
await expect(
|
||||
store.withLoading(async () => {
|
||||
throw new Error('操作失败')
|
||||
})
|
||||
).rejects.toThrow('操作失败')
|
||||
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('withLoadingAndError 错误时显示 toast 并返回 null', async () => {
|
||||
const store = useAppStore()
|
||||
|
||||
const result = await store.withLoadingAndError(async () => {
|
||||
throw new Error('网络错误')
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].type).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
// --- reset ---
|
||||
|
||||
describe('reset', () => {
|
||||
it('重置所有 UI 状态', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setSidebarCollapsed(true)
|
||||
store.setLoading(true)
|
||||
store.showSuccess('消息')
|
||||
|
||||
store.reset()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.toasts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 公开设置 ---
|
||||
|
||||
describe('公开设置加载', () => {
|
||||
it('从 window.__APP_CONFIG__ 初始化', () => {
|
||||
const windowAny = window as any
|
||||
windowAny.__APP_CONFIG__ = {
|
||||
site_name: 'TestSite',
|
||||
site_logo: '/logo.png',
|
||||
version: '1.0.0',
|
||||
contact_info: 'test@test.com',
|
||||
api_base_url: 'https://api.test.com',
|
||||
doc_url: 'https://docs.test.com',
|
||||
}
|
||||
|
||||
const store = useAppStore()
|
||||
const result = store.initFromInjectedConfig()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.siteName).toBe('TestSite')
|
||||
expect(store.siteLogo).toBe('/logo.png')
|
||||
expect(store.siteVersion).toBe('1.0.0')
|
||||
expect(store.publicSettingsLoaded).toBe(true)
|
||||
})
|
||||
|
||||
it('无注入配置时返回 false', () => {
|
||||
const store = useAppStore()
|
||||
const result = store.initFromInjectedConfig()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.publicSettingsLoaded).toBe(false)
|
||||
})
|
||||
|
||||
it('clearPublicSettingsCache 清除缓存', () => {
|
||||
const windowAny = window as any
|
||||
windowAny.__APP_CONFIG__ = { site_name: 'Test' }
|
||||
const store = useAppStore()
|
||||
store.initFromInjectedConfig()
|
||||
|
||||
expect(store.publicSettingsLoaded).toBe(true)
|
||||
|
||||
store.clearPublicSettingsCache()
|
||||
|
||||
expect(store.publicSettingsLoaded).toBe(false)
|
||||
expect(store.cachedPublicSettings).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
289
frontend/src/stores/__tests__/auth.spec.ts
Normal file
289
frontend/src/stores/__tests__/auth.spec.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Mock authAPI
|
||||
const mockLogin = vi.fn()
|
||||
const mockLogin2FA = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
const mockGetCurrentUser = vi.fn()
|
||||
const mockRegister = vi.fn()
|
||||
const mockRefreshToken = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
authAPI: {
|
||||
login: (...args: any[]) => mockLogin(...args),
|
||||
login2FA: (...args: any[]) => mockLogin2FA(...args),
|
||||
logout: (...args: any[]) => mockLogout(...args),
|
||||
getCurrentUser: (...args: any[]) => mockGetCurrentUser(...args),
|
||||
register: (...args: any[]) => mockRegister(...args),
|
||||
refreshToken: (...args: any[]) => mockRefreshToken(...args),
|
||||
},
|
||||
isTotp2FARequired: (response: any) => response?.requires_2fa === true,
|
||||
}))
|
||||
|
||||
const fakeUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
role: 'user' as const,
|
||||
balance: 100,
|
||||
concurrency: 5,
|
||||
status: 'active' as const,
|
||||
allowed_groups: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
}
|
||||
|
||||
const fakeAdminUser = {
|
||||
...fakeUser,
|
||||
id: 2,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin' as const,
|
||||
}
|
||||
|
||||
const fakeAuthResponse = {
|
||||
access_token: 'test-token-123',
|
||||
refresh_token: 'refresh-token-456',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
user: { ...fakeUser },
|
||||
}
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// --- login ---
|
||||
|
||||
describe('login', () => {
|
||||
it('成功登录后设置 token 和 user', async () => {
|
||||
mockLogin.mockResolvedValue(fakeAuthResponse)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.login({ email: 'test@example.com', password: '123456' })
|
||||
|
||||
expect(store.token).toBe('test-token-123')
|
||||
expect(store.user).toEqual(fakeUser)
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('auth_token')).toBe('test-token-123')
|
||||
expect(localStorage.getItem('auth_user')).toBe(JSON.stringify(fakeUser))
|
||||
})
|
||||
|
||||
it('登录失败时清除状态并抛出错误', async () => {
|
||||
mockLogin.mockRejectedValue(new Error('Invalid credentials'))
|
||||
const store = useAuthStore()
|
||||
|
||||
await expect(store.login({ email: 'test@example.com', password: 'wrong' })).rejects.toThrow(
|
||||
'Invalid credentials'
|
||||
)
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('需要 2FA 时返回响应但不设置认证状态', async () => {
|
||||
const twoFAResponse = { requires_2fa: true, temp_token: 'temp-123' }
|
||||
mockLogin.mockResolvedValue(twoFAResponse)
|
||||
const store = useAuthStore()
|
||||
|
||||
const result = await store.login({ email: 'test@example.com', password: '123456' })
|
||||
|
||||
expect(result).toEqual(twoFAResponse)
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- login2FA ---
|
||||
|
||||
describe('login2FA', () => {
|
||||
it('2FA 验证成功后设置认证状态', async () => {
|
||||
mockLogin2FA.mockResolvedValue(fakeAuthResponse)
|
||||
const store = useAuthStore()
|
||||
|
||||
const user = await store.login2FA('temp-123', '654321')
|
||||
|
||||
expect(store.token).toBe('test-token-123')
|
||||
expect(store.user).toEqual(fakeUser)
|
||||
expect(user).toEqual(fakeUser)
|
||||
expect(mockLogin2FA).toHaveBeenCalledWith({
|
||||
temp_token: 'temp-123',
|
||||
totp_code: '654321',
|
||||
})
|
||||
})
|
||||
|
||||
it('2FA 验证失败时清除状态并抛出错误', async () => {
|
||||
mockLogin2FA.mockRejectedValue(new Error('Invalid TOTP'))
|
||||
const store = useAuthStore()
|
||||
|
||||
await expect(store.login2FA('temp-123', '000000')).rejects.toThrow('Invalid TOTP')
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- logout ---
|
||||
|
||||
describe('logout', () => {
|
||||
it('注销后清除所有状态和 localStorage', async () => {
|
||||
mockLogin.mockResolvedValue(fakeAuthResponse)
|
||||
mockLogout.mockResolvedValue(undefined)
|
||||
const store = useAuthStore()
|
||||
|
||||
// 先登录
|
||||
await store.login({ email: 'test@example.com', password: '123456' })
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
|
||||
// 注销
|
||||
await store.logout()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
expect(localStorage.getItem('auth_user')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
expect(localStorage.getItem('token_expires_at')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- checkAuth ---
|
||||
|
||||
describe('checkAuth', () => {
|
||||
it('从 localStorage 恢复持久化状态', () => {
|
||||
localStorage.setItem('auth_token', 'saved-token')
|
||||
localStorage.setItem('auth_user', JSON.stringify(fakeUser))
|
||||
|
||||
// Mock refreshUser (getCurrentUser) 防止后台刷新报错
|
||||
mockGetCurrentUser.mockResolvedValue({ data: fakeUser })
|
||||
|
||||
const store = useAuthStore()
|
||||
store.checkAuth()
|
||||
|
||||
expect(store.token).toBe('saved-token')
|
||||
expect(store.user).toEqual(fakeUser)
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('localStorage 无数据时保持未认证状态', () => {
|
||||
const store = useAuthStore()
|
||||
store.checkAuth()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('localStorage 中用户数据损坏时清除状态', () => {
|
||||
localStorage.setItem('auth_token', 'saved-token')
|
||||
localStorage.setItem('auth_user', 'invalid-json{{{')
|
||||
|
||||
const store = useAuthStore()
|
||||
store.checkAuth()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('恢复 refresh token 和过期时间', () => {
|
||||
const futureTs = String(Date.now() + 3600_000)
|
||||
localStorage.setItem('auth_token', 'saved-token')
|
||||
localStorage.setItem('auth_user', JSON.stringify(fakeUser))
|
||||
localStorage.setItem('refresh_token', 'saved-refresh')
|
||||
localStorage.setItem('token_expires_at', futureTs)
|
||||
|
||||
mockGetCurrentUser.mockResolvedValue({ data: fakeUser })
|
||||
|
||||
const store = useAuthStore()
|
||||
store.checkAuth()
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- isAdmin ---
|
||||
|
||||
describe('isAdmin', () => {
|
||||
it('管理员用户返回 true', async () => {
|
||||
const adminResponse = { ...fakeAuthResponse, user: { ...fakeAdminUser } }
|
||||
mockLogin.mockResolvedValue(adminResponse)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.login({ email: 'admin@example.com', password: '123456' })
|
||||
|
||||
expect(store.isAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('普通用户返回 false', async () => {
|
||||
mockLogin.mockResolvedValue(fakeAuthResponse)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.login({ email: 'test@example.com', password: '123456' })
|
||||
|
||||
expect(store.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('未登录时返回 false', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isAdmin).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- refreshUser ---
|
||||
|
||||
describe('refreshUser', () => {
|
||||
it('刷新用户数据并更新 localStorage', async () => {
|
||||
mockLogin.mockResolvedValue(fakeAuthResponse)
|
||||
const store = useAuthStore()
|
||||
await store.login({ email: 'test@example.com', password: '123456' })
|
||||
|
||||
const updatedUser = { ...fakeUser, username: 'updated-name' }
|
||||
mockGetCurrentUser.mockResolvedValue({ data: updatedUser })
|
||||
|
||||
const result = await store.refreshUser()
|
||||
|
||||
expect(result).toEqual(updatedUser)
|
||||
expect(store.user).toEqual(updatedUser)
|
||||
expect(JSON.parse(localStorage.getItem('auth_user')!)).toEqual(updatedUser)
|
||||
})
|
||||
|
||||
it('未认证时抛出错误', async () => {
|
||||
const store = useAuthStore()
|
||||
await expect(store.refreshUser()).rejects.toThrow('Not authenticated')
|
||||
})
|
||||
})
|
||||
|
||||
// --- isSimpleMode ---
|
||||
|
||||
describe('isSimpleMode', () => {
|
||||
it('run_mode 为 simple 时返回 true', async () => {
|
||||
const simpleResponse = {
|
||||
...fakeAuthResponse,
|
||||
user: { ...fakeUser, run_mode: 'simple' as const },
|
||||
}
|
||||
mockLogin.mockResolvedValue(simpleResponse)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.login({ email: 'test@example.com', password: '123456' })
|
||||
|
||||
expect(store.isSimpleMode).toBe(true)
|
||||
})
|
||||
|
||||
it('默认为 standard 模式', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isSimpleMode).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
239
frontend/src/stores/__tests__/subscriptions.spec.ts
Normal file
239
frontend/src/stores/__tests__/subscriptions.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
|
||||
// Mock subscriptions API
|
||||
const mockGetActiveSubscriptions = vi.fn()
|
||||
|
||||
vi.mock('@/api/subscriptions', () => ({
|
||||
default: {
|
||||
getActiveSubscriptions: (...args: any[]) => mockGetActiveSubscriptions(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const fakeSubscriptions = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
group_id: 1,
|
||||
status: 'active' as const,
|
||||
daily_usage_usd: 5,
|
||||
weekly_usage_usd: 20,
|
||||
monthly_usage_usd: 50,
|
||||
daily_window_start: null,
|
||||
weekly_window_start: null,
|
||||
monthly_window_start: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
expires_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user_id: 1,
|
||||
group_id: 2,
|
||||
status: 'active' as const,
|
||||
daily_usage_usd: 10,
|
||||
weekly_usage_usd: 40,
|
||||
monthly_usage_usd: 100,
|
||||
daily_window_start: null,
|
||||
weekly_window_start: null,
|
||||
monthly_window_start: null,
|
||||
created_at: '2024-02-01',
|
||||
updated_at: '2024-02-01',
|
||||
expires_at: '2025-02-01',
|
||||
},
|
||||
]
|
||||
|
||||
describe('useSubscriptionStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// --- fetchActiveSubscriptions ---
|
||||
|
||||
describe('fetchActiveSubscriptions', () => {
|
||||
it('成功获取活跃订阅', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
const result = await store.fetchActiveSubscriptions()
|
||||
|
||||
expect(result).toEqual(fakeSubscriptions)
|
||||
expect(store.activeSubscriptions).toEqual(fakeSubscriptions)
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('缓存有效时返回缓存数据', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
// 第一次请求
|
||||
await store.fetchActiveSubscriptions()
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 第二次请求(60秒内)- 应返回缓存
|
||||
const result = await store.fetchActiveSubscriptions()
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(1) // 没有新请求
|
||||
expect(result).toEqual(fakeSubscriptions)
|
||||
})
|
||||
|
||||
it('缓存过期后重新请求', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
await store.fetchActiveSubscriptions()
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 推进 61 秒让缓存过期
|
||||
vi.advanceTimersByTime(61_000)
|
||||
|
||||
const updatedSubs = [fakeSubscriptions[0]]
|
||||
mockGetActiveSubscriptions.mockResolvedValue(updatedSubs)
|
||||
|
||||
const result = await store.fetchActiveSubscriptions()
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(2)
|
||||
expect(result).toEqual(updatedSubs)
|
||||
})
|
||||
|
||||
it('force=true 强制重新请求', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
await store.fetchActiveSubscriptions()
|
||||
|
||||
const updatedSubs = [fakeSubscriptions[0]]
|
||||
mockGetActiveSubscriptions.mockResolvedValue(updatedSubs)
|
||||
|
||||
const result = await store.fetchActiveSubscriptions(true)
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(2)
|
||||
expect(result).toEqual(updatedSubs)
|
||||
})
|
||||
|
||||
it('并发请求共享同一个 Promise(去重)', async () => {
|
||||
let resolvePromise: (v: any) => void
|
||||
mockGetActiveSubscriptions.mockImplementation(
|
||||
() => new Promise((resolve) => { resolvePromise = resolve })
|
||||
)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
// 并发发起两个请求
|
||||
const p1 = store.fetchActiveSubscriptions()
|
||||
const p2 = store.fetchActiveSubscriptions()
|
||||
|
||||
// 只调用了一次 API
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 解决 Promise
|
||||
resolvePromise!(fakeSubscriptions)
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2])
|
||||
expect(r1).toEqual(fakeSubscriptions)
|
||||
expect(r2).toEqual(fakeSubscriptions)
|
||||
})
|
||||
|
||||
it('API 错误时抛出异常', async () => {
|
||||
mockGetActiveSubscriptions.mockRejectedValue(new Error('Network error'))
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
await expect(store.fetchActiveSubscriptions()).rejects.toThrow('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
// --- hasActiveSubscriptions ---
|
||||
|
||||
describe('hasActiveSubscriptions', () => {
|
||||
it('有订阅时返回 true', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
await store.fetchActiveSubscriptions()
|
||||
|
||||
expect(store.hasActiveSubscriptions).toBe(true)
|
||||
})
|
||||
|
||||
it('无订阅时返回 false', () => {
|
||||
const store = useSubscriptionStore()
|
||||
expect(store.hasActiveSubscriptions).toBe(false)
|
||||
})
|
||||
|
||||
it('清除后返回 false', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
await store.fetchActiveSubscriptions()
|
||||
expect(store.hasActiveSubscriptions).toBe(true)
|
||||
|
||||
store.clear()
|
||||
expect(store.hasActiveSubscriptions).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- invalidateCache ---
|
||||
|
||||
describe('invalidateCache', () => {
|
||||
it('失效缓存后下次请求重新获取数据', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
await store.fetchActiveSubscriptions()
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(1)
|
||||
|
||||
store.invalidateCache()
|
||||
|
||||
await store.fetchActiveSubscriptions()
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --- clear ---
|
||||
|
||||
describe('clear', () => {
|
||||
it('清除所有订阅数据', async () => {
|
||||
mockGetActiveSubscriptions.mockResolvedValue(fakeSubscriptions)
|
||||
const store = useSubscriptionStore()
|
||||
|
||||
await store.fetchActiveSubscriptions()
|
||||
expect(store.activeSubscriptions).toHaveLength(2)
|
||||
|
||||
store.clear()
|
||||
|
||||
expect(store.activeSubscriptions).toHaveLength(0)
|
||||
expect(store.hasActiveSubscriptions).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- polling ---
|
||||
|
||||
describe('startPolling / stopPolling', () => {
|
||||
it('startPolling 不会创建重复 interval', () => {
|
||||
const store = useSubscriptionStore()
|
||||
mockGetActiveSubscriptions.mockResolvedValue([])
|
||||
|
||||
store.startPolling()
|
||||
store.startPolling() // 重复调用
|
||||
|
||||
// 推进5分钟只触发一次
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
expect(mockGetActiveSubscriptions).toHaveBeenCalledTimes(1)
|
||||
|
||||
store.stopPolling()
|
||||
})
|
||||
|
||||
it('stopPolling 停止定期刷新', () => {
|
||||
const store = useSubscriptionStore()
|
||||
mockGetActiveSubscriptions.mockResolvedValue([])
|
||||
|
||||
store.startPolling()
|
||||
store.stopPolling()
|
||||
|
||||
vi.advanceTimersByTime(10 * 60 * 1000)
|
||||
expect(mockGetActiveSubscriptions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -338,7 +338,7 @@ export interface PaginationConfig {
|
||||
|
||||
// ==================== API Key & Group Types ====================
|
||||
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
|
||||
|
||||
export type SubscriptionType = 'standard' | 'subscription'
|
||||
|
||||
@@ -358,6 +358,11 @@ export interface Group {
|
||||
image_price_1k: number | null
|
||||
image_price_2k: number | null
|
||||
image_price_4k: number | null
|
||||
// Sora 按次计费配置
|
||||
sora_image_price_360: number | null
|
||||
sora_image_price_540: number | null
|
||||
sora_video_price_per_request: number | null
|
||||
sora_video_price_per_request_hd: number | null
|
||||
// Claude Code 客户端限制
|
||||
claude_code_only: boolean
|
||||
fallback_group_id: number | null
|
||||
@@ -393,6 +398,7 @@ export interface ApiKey {
|
||||
status: 'active' | 'inactive' | 'quota_exhausted' | 'expired'
|
||||
ip_whitelist: string[]
|
||||
ip_blacklist: string[]
|
||||
last_used_at: string | null
|
||||
quota: number // Quota limit in USD (0 = unlimited)
|
||||
quota_used: number // Used quota amount in USD
|
||||
expires_at: string | null // Expiration time (null = never expires)
|
||||
@@ -435,6 +441,10 @@ export interface CreateGroupRequest {
|
||||
image_price_1k?: number | null
|
||||
image_price_2k?: number | null
|
||||
image_price_4k?: number | null
|
||||
sora_image_price_360?: number | null
|
||||
sora_image_price_540?: number | null
|
||||
sora_video_price_per_request?: number | null
|
||||
sora_video_price_per_request_hd?: number | null
|
||||
claude_code_only?: boolean
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
@@ -458,6 +468,10 @@ export interface UpdateGroupRequest {
|
||||
image_price_1k?: number | null
|
||||
image_price_2k?: number | null
|
||||
image_price_4k?: number | null
|
||||
sora_image_price_360?: number | null
|
||||
sora_image_price_540?: number | null
|
||||
sora_video_price_per_request?: number | null
|
||||
sora_video_price_per_request_hd?: number | null
|
||||
claude_code_only?: boolean
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
@@ -468,7 +482,7 @@ export interface UpdateGroupRequest {
|
||||
|
||||
// ==================== Account & Proxy Types ====================
|
||||
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
|
||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream'
|
||||
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
||||
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
|
||||
@@ -499,6 +513,11 @@ export interface Proxy {
|
||||
country_code?: string
|
||||
region?: string
|
||||
city?: string
|
||||
quality_status?: 'healthy' | 'warn' | 'challenge' | 'failed'
|
||||
quality_score?: number
|
||||
quality_grade?: string
|
||||
quality_summary?: string
|
||||
quality_checked?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -511,6 +530,32 @@ export interface ProxyAccountSummary {
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export interface ProxyQualityCheckItem {
|
||||
target: string
|
||||
status: 'pass' | 'warn' | 'fail' | 'challenge'
|
||||
http_status?: number
|
||||
latency_ms?: number
|
||||
message?: string
|
||||
cf_ray?: string
|
||||
}
|
||||
|
||||
export interface ProxyQualityCheckResult {
|
||||
proxy_id: number
|
||||
score: number
|
||||
grade: string
|
||||
summary: string
|
||||
exit_ip?: string
|
||||
country?: string
|
||||
country_code?: string
|
||||
base_latency_ms?: number
|
||||
passed_count: number
|
||||
warn_count: number
|
||||
failed_count: number
|
||||
challenge_count: number
|
||||
checked_at: number
|
||||
items: ProxyQualityCheckItem[]
|
||||
}
|
||||
|
||||
// Gemini credentials structure for OAuth and API Key authentication
|
||||
export interface GeminiCredentials {
|
||||
// API Key authentication
|
||||
@@ -676,9 +721,11 @@ export interface CodexUsageSnapshot {
|
||||
// Canonical fields (normalized by backend, use these preferentially)
|
||||
codex_5h_used_percent?: number // 5-hour window usage percentage
|
||||
codex_5h_reset_after_seconds?: number // Seconds until 5h window reset
|
||||
codex_5h_reset_at?: string // 5-hour window absolute reset time (RFC3339)
|
||||
codex_5h_window_minutes?: number // 5h window in minutes (should be ~300)
|
||||
codex_7d_used_percent?: number // 7-day window usage percentage
|
||||
codex_7d_reset_after_seconds?: number // Seconds until 7d window reset
|
||||
codex_7d_reset_at?: string // 7-day window absolute reset time (RFC3339)
|
||||
codex_7d_window_minutes?: number // 7d window in minutes (should be ~10080)
|
||||
|
||||
codex_usage_updated_at?: string // Last update timestamp
|
||||
|
||||
206
frontend/src/utils/__tests__/codexUsage.spec.ts
Normal file
206
frontend/src/utils/__tests__/codexUsage.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||
|
||||
describe('resolveCodexUsageWindow', () => {
|
||||
it('快照为空时返回空窗口', () => {
|
||||
const result = resolveCodexUsageWindow(null, '5h', new Date('2026-02-20T08:00:00Z'))
|
||||
expect(result).toEqual({ usedPercent: null, resetAt: null })
|
||||
})
|
||||
|
||||
it('优先使用后端提供的绝对重置时间', () => {
|
||||
const now = new Date('2026-02-20T08:00:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 55,
|
||||
codex_5h_reset_at: '2026-02-20T10:00:00Z',
|
||||
codex_5h_reset_after_seconds: 1
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(55)
|
||||
expect(result.resetAt).toBe('2026-02-20T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('窗口已过期时自动归零', () => {
|
||||
const now = new Date('2026-02-20T08:00:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_7d_used_percent: 100,
|
||||
codex_7d_reset_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(0)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:00:00.000Z')
|
||||
})
|
||||
|
||||
it('无绝对时间时使用 updated_at + seconds 回退计算', () => {
|
||||
const now = new Date('2026-02-20T07:00:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 20,
|
||||
codex_5h_reset_after_seconds: 3600,
|
||||
codex_usage_updated_at: '2026-02-20T06:30:00Z'
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(20)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:30:00.000Z')
|
||||
})
|
||||
|
||||
it('支持 legacy primary/secondary 字段映射', () => {
|
||||
const now = new Date('2026-02-20T07:05:00Z')
|
||||
const result5h = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: 10080,
|
||||
codex_primary_used_percent: 70,
|
||||
codex_primary_reset_after_seconds: 86400,
|
||||
codex_secondary_window_minutes: 300,
|
||||
codex_secondary_used_percent: 15,
|
||||
codex_secondary_reset_after_seconds: 1200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
const result7d = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: 10080,
|
||||
codex_primary_used_percent: 70,
|
||||
codex_primary_reset_after_seconds: 86400,
|
||||
codex_secondary_window_minutes: 300,
|
||||
codex_secondary_used_percent: 15,
|
||||
codex_secondary_reset_after_seconds: 1200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result5h.usedPercent).toBe(15)
|
||||
expect(result5h.resetAt).toBe('2026-02-20T07:20:00.000Z')
|
||||
expect(result7d.usedPercent).toBe(70)
|
||||
expect(result7d.resetAt).toBe('2026-02-21T07:00:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 5h 在 primary<=360 时优先 primary 并支持字符串数字', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: '300',
|
||||
codex_primary_used_percent: '21',
|
||||
codex_primary_reset_after_seconds: '1800',
|
||||
codex_secondary_window_minutes: '10080',
|
||||
codex_secondary_used_percent: '99',
|
||||
codex_secondary_reset_after_seconds: '99999',
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:10:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(21)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:30:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 5h 在无窗口信息时回退 secondary', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_secondary_used_percent: 19,
|
||||
codex_secondary_reset_after_seconds: 120,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:00:01Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(19)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:02:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 场景下 secondary 为 7d 时能正确识别', () => {
|
||||
const now = new Date('2026-02-20T07:30:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: 300,
|
||||
codex_primary_used_percent: 5,
|
||||
codex_primary_reset_after_seconds: 600,
|
||||
codex_secondary_window_minutes: 10080,
|
||||
codex_secondary_used_percent: 66,
|
||||
codex_secondary_reset_after_seconds: 7200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(66)
|
||||
expect(result.resetAt).toBe('2026-02-20T09:00:00.000Z')
|
||||
})
|
||||
|
||||
it('绝对时间非法时回退到 updated_at + seconds', () => {
|
||||
const now = new Date('2026-02-20T07:40:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 33,
|
||||
codex_5h_reset_at: 'not-a-date',
|
||||
codex_5h_reset_after_seconds: 900,
|
||||
codex_usage_updated_at: '2026-02-20T07:30:00Z'
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(33)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:45:00.000Z')
|
||||
})
|
||||
|
||||
it('updated_at 非法且无绝对时间时 resetAt 返回 null', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 10,
|
||||
codex_5h_reset_after_seconds: 123,
|
||||
codex_usage_updated_at: 'invalid-time'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:00:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(10)
|
||||
expect(result.resetAt).toBeNull()
|
||||
})
|
||||
|
||||
it('reset_after_seconds 为负数时按 0 秒处理', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 80,
|
||||
codex_5h_reset_after_seconds: -30,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T07:59:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(80)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('百分比缺失时仍可计算 resetAt 供倒计时展示', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_7d_reset_after_seconds: 60,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
new Date('2026-02-20T08:00:01Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBeNull()
|
||||
expect(result.resetAt).toBe('2026-02-20T08:01:00.000Z')
|
||||
})
|
||||
})
|
||||
37
frontend/src/utils/__tests__/stableObjectKey.spec.ts
Normal file
37
frontend/src/utils/__tests__/stableObjectKey.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
describe('createStableObjectKeyResolver', () => {
|
||||
it('对同一对象返回稳定 key', () => {
|
||||
const resolve = createStableObjectKeyResolver<{ value: string }>('rule')
|
||||
const obj = { value: 'a' }
|
||||
|
||||
const key1 = resolve(obj)
|
||||
const key2 = resolve(obj)
|
||||
|
||||
expect(key1).toBe(key2)
|
||||
expect(key1.startsWith('rule-')).toBe(true)
|
||||
})
|
||||
|
||||
it('不同对象返回不同 key', () => {
|
||||
const resolve = createStableObjectKeyResolver<{ value: string }>('rule')
|
||||
|
||||
const key1 = resolve({ value: 'a' })
|
||||
const key2 = resolve({ value: 'a' })
|
||||
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('不同 resolver 互不影响', () => {
|
||||
const resolveA = createStableObjectKeyResolver<{ id: number }>('a')
|
||||
const resolveB = createStableObjectKeyResolver<{ id: number }>('b')
|
||||
const obj = { id: 1 }
|
||||
|
||||
const keyA = resolveA(obj)
|
||||
const keyB = resolveB(obj)
|
||||
|
||||
expect(keyA).not.toBe(keyB)
|
||||
expect(keyA.startsWith('a-')).toBe(true)
|
||||
expect(keyB.startsWith('b-')).toBe(true)
|
||||
})
|
||||
})
|
||||
130
frontend/src/utils/codexUsage.ts
Normal file
130
frontend/src/utils/codexUsage.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { CodexUsageSnapshot } from '@/types'
|
||||
|
||||
export interface ResolvedCodexUsageWindow {
|
||||
usedPercent: number | null
|
||||
resetAt: string | null
|
||||
}
|
||||
|
||||
type WindowKind = '5h' | '7d'
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n)) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
|
||||
function asISOTime(value: unknown): string | null {
|
||||
const raw = asString(value)
|
||||
if (!raw) return null
|
||||
const date = new Date(raw)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function resolveLegacy5h(snapshot: Record<string, unknown>): { used: number | null; resetAfterSeconds: number | null } {
|
||||
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
|
||||
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
|
||||
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
|
||||
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
|
||||
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
|
||||
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
|
||||
|
||||
if (primaryWindow != null && primaryWindow <= 360) {
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
if (secondaryWindow != null && secondaryWindow <= 360) {
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
|
||||
function resolveLegacy7d(snapshot: Record<string, unknown>): { used: number | null; resetAfterSeconds: number | null } {
|
||||
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
|
||||
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
|
||||
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
|
||||
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
|
||||
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
|
||||
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
|
||||
|
||||
if (primaryWindow != null && primaryWindow >= 10000) {
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
if (secondaryWindow != null && secondaryWindow >= 10000) {
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
|
||||
function resolveFromSeconds(snapshot: Record<string, unknown>, resetAfterSeconds: number | null): string | null {
|
||||
if (resetAfterSeconds == null) return null
|
||||
|
||||
const baseRaw = asString(snapshot.codex_usage_updated_at)
|
||||
const base = baseRaw ? new Date(baseRaw) : new Date()
|
||||
if (Number.isNaN(base.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sec = Math.max(0, resetAfterSeconds)
|
||||
const resetAt = new Date(base.getTime() + sec * 1000)
|
||||
return resetAt.toISOString()
|
||||
}
|
||||
|
||||
function applyExpiredRule(window: ResolvedCodexUsageWindow, now: Date): ResolvedCodexUsageWindow {
|
||||
if (window.usedPercent == null || !window.resetAt) return window
|
||||
const resetDate = new Date(window.resetAt)
|
||||
if (Number.isNaN(resetDate.getTime())) return window
|
||||
if (resetDate.getTime() <= now.getTime()) {
|
||||
return { usedPercent: 0, resetAt: resetDate.toISOString() }
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
export function resolveCodexUsageWindow(
|
||||
snapshot: (CodexUsageSnapshot & Record<string, unknown>) | null | undefined,
|
||||
window: WindowKind,
|
||||
now: Date = new Date()
|
||||
): ResolvedCodexUsageWindow {
|
||||
if (!snapshot) {
|
||||
return { usedPercent: null, resetAt: null }
|
||||
}
|
||||
|
||||
const typedSnapshot = snapshot as Record<string, unknown>
|
||||
let usedPercent: number | null
|
||||
let resetAfterSeconds: number | null
|
||||
let resetAt: string | null
|
||||
|
||||
if (window === '5h') {
|
||||
usedPercent = asNumber(typedSnapshot.codex_5h_used_percent)
|
||||
resetAfterSeconds = asNumber(typedSnapshot.codex_5h_reset_after_seconds)
|
||||
resetAt = asISOTime(typedSnapshot.codex_5h_reset_at)
|
||||
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
|
||||
const legacy = resolveLegacy5h(typedSnapshot)
|
||||
if (usedPercent == null) usedPercent = legacy.used
|
||||
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
|
||||
}
|
||||
} else {
|
||||
usedPercent = asNumber(typedSnapshot.codex_7d_used_percent)
|
||||
resetAfterSeconds = asNumber(typedSnapshot.codex_7d_reset_after_seconds)
|
||||
resetAt = asISOTime(typedSnapshot.codex_7d_reset_at)
|
||||
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
|
||||
const legacy = resolveLegacy7d(typedSnapshot)
|
||||
if (usedPercent == null) usedPercent = legacy.used
|
||||
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
|
||||
}
|
||||
}
|
||||
|
||||
if (!resetAt) {
|
||||
resetAt = resolveFromSeconds(typedSnapshot, resetAfterSeconds)
|
||||
}
|
||||
|
||||
return applyExpiredRule({ usedPercent, resetAt }, now)
|
||||
}
|
||||
19
frontend/src/utils/stableObjectKey.ts
Normal file
19
frontend/src/utils/stableObjectKey.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
let globalStableObjectKeySeed = 0
|
||||
|
||||
/**
|
||||
* 为对象实例生成稳定 key(基于 WeakMap,不污染业务对象)
|
||||
*/
|
||||
export function createStableObjectKeyResolver<T extends object>(prefix = 'item') {
|
||||
const keyMap = new WeakMap<T, string>()
|
||||
|
||||
return (item: T): string => {
|
||||
const cached = keyMap.get(item)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const key = `${prefix}-${++globalStableObjectKeySeed}`
|
||||
keyMap.set(item, key)
|
||||
return key
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
/>
|
||||
<AccountTableActions
|
||||
:loading="loading"
|
||||
@refresh="load"
|
||||
@refresh="handleManualRefresh"
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
>
|
||||
@@ -117,6 +117,18 @@
|
||||
</template>
|
||||
</AccountTableActions>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasPendingListSync"
|
||||
class="mt-2 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<span>{{ t('admin.accounts.listPendingSyncHint') }}</span>
|
||||
<button
|
||||
class="btn btn-secondary px-2 py-1 text-xs"
|
||||
@click="syncPendingListChanges"
|
||||
>
|
||||
{{ t('admin.accounts.listPendingSyncAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
@@ -240,8 +252,8 @@
|
||||
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /></template>
|
||||
</TablePageLayout>
|
||||
<CreateAccountModal :show="showCreate" :proxies="proxies" :groups="groups" @close="showCreate = false" @created="reload" />
|
||||
<EditAccountModal :show="showEdit" :account="edAcc" :proxies="proxies" :groups="groups" @close="showEdit = false" @updated="load" />
|
||||
<ReAuthAccountModal :show="showReAuth" :account="reAuthAcc" @close="closeReAuthModal" @reauthorized="load" />
|
||||
<EditAccountModal :show="showEdit" :account="edAcc" :proxies="proxies" :groups="groups" @close="showEdit = false" @updated="handleAccountUpdated" />
|
||||
<ReAuthAccountModal :show="showReAuth" :account="reAuthAcc" @close="closeReAuthModal" @reauthorized="handleAccountUpdated" />
|
||||
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
|
||||
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
|
||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
|
||||
@@ -261,7 +273,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, toRaw } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -341,6 +353,11 @@ const autoRefreshIntervals = [5, 10, 15, 30] as const
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
||||
const autoRefreshCountdown = ref(0)
|
||||
const autoRefreshETag = ref<string | null>(null)
|
||||
const autoRefreshFetching = ref(false)
|
||||
const AUTO_REFRESH_SILENT_WINDOW_MS = 15000
|
||||
const autoRefreshSilentUntil = ref(0)
|
||||
const hasPendingListSync = ref(false)
|
||||
|
||||
const autoRefreshIntervalLabel = (sec: number) => {
|
||||
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||
@@ -438,11 +455,55 @@ const toggleColumn = (key: string) => {
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
||||
const {
|
||||
items: accounts,
|
||||
loading,
|
||||
params,
|
||||
pagination,
|
||||
load: baseLoad,
|
||||
reload: baseReload,
|
||||
debouncedReload: baseDebouncedReload,
|
||||
handlePageChange: baseHandlePageChange,
|
||||
handlePageSizeChange: baseHandlePageSizeChange
|
||||
} = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', group: '', search: '' }
|
||||
})
|
||||
|
||||
const resetAutoRefreshCache = () => {
|
||||
autoRefreshETag.value = null
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
await baseLoad()
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
await baseReload()
|
||||
}
|
||||
|
||||
const debouncedReload = () => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
baseDebouncedReload()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
baseHandlePageChange(page)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
baseHandlePageSizeChange(size)
|
||||
}
|
||||
|
||||
const isAnyModalOpen = computed(() => {
|
||||
return (
|
||||
showCreate.value ||
|
||||
@@ -460,21 +521,128 @@ const isAnyModalOpen = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const enterAutoRefreshSilentWindow = () => {
|
||||
autoRefreshSilentUntil.value = Date.now() + AUTO_REFRESH_SILENT_WINDOW_MS
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
}
|
||||
|
||||
const inAutoRefreshSilentWindow = () => {
|
||||
return Date.now() < autoRefreshSilentUntil.value
|
||||
}
|
||||
|
||||
const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => {
|
||||
return (
|
||||
current.updated_at !== next.updated_at ||
|
||||
current.current_concurrency !== next.current_concurrency ||
|
||||
current.current_window_cost !== next.current_window_cost ||
|
||||
current.active_sessions !== next.active_sessions ||
|
||||
current.schedulable !== next.schedulable ||
|
||||
current.status !== next.status ||
|
||||
current.rate_limit_reset_at !== next.rate_limit_reset_at ||
|
||||
current.overload_until !== next.overload_until ||
|
||||
current.temp_unschedulable_until !== next.temp_unschedulable_until
|
||||
)
|
||||
}
|
||||
|
||||
const syncAccountRefs = (nextAccount: Account) => {
|
||||
if (edAcc.value?.id === nextAccount.id) edAcc.value = nextAccount
|
||||
if (reAuthAcc.value?.id === nextAccount.id) reAuthAcc.value = nextAccount
|
||||
if (tempUnschedAcc.value?.id === nextAccount.id) tempUnschedAcc.value = nextAccount
|
||||
if (deletingAcc.value?.id === nextAccount.id) deletingAcc.value = nextAccount
|
||||
if (menu.acc?.id === nextAccount.id) menu.acc = nextAccount
|
||||
}
|
||||
|
||||
const mergeAccountsIncrementally = (nextRows: Account[]) => {
|
||||
const currentRows = accounts.value
|
||||
const currentByID = new Map(currentRows.map(row => [row.id, row]))
|
||||
let changed = nextRows.length !== currentRows.length
|
||||
const mergedRows = nextRows.map((nextRow) => {
|
||||
const currentRow = currentByID.get(nextRow.id)
|
||||
if (!currentRow) {
|
||||
changed = true
|
||||
return nextRow
|
||||
}
|
||||
if (shouldReplaceAutoRefreshRow(currentRow, nextRow)) {
|
||||
changed = true
|
||||
syncAccountRefs(nextRow)
|
||||
return nextRow
|
||||
}
|
||||
return currentRow
|
||||
})
|
||||
if (!changed) {
|
||||
for (let i = 0; i < mergedRows.length; i += 1) {
|
||||
if (mergedRows[i].id !== currentRows[i]?.id) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
accounts.value = mergedRows
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAccountsIncrementally = async () => {
|
||||
if (autoRefreshFetching.value) return
|
||||
autoRefreshFetching.value = true
|
||||
try {
|
||||
const result = await adminAPI.accounts.listWithEtag(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
toRaw(params) as {
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
},
|
||||
{ etag: autoRefreshETag.value }
|
||||
)
|
||||
|
||||
if (result.etag) {
|
||||
autoRefreshETag.value = result.etag
|
||||
}
|
||||
if (result.notModified || !result.data) {
|
||||
return
|
||||
}
|
||||
|
||||
pagination.total = result.data.total || 0
|
||||
pagination.pages = result.data.pages || 0
|
||||
mergeAccountsIncrementally(result.data.items || [])
|
||||
hasPendingListSync.value = false
|
||||
} catch (error) {
|
||||
console.error('Auto refresh failed:', error)
|
||||
} finally {
|
||||
autoRefreshFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualRefresh = async () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
const syncPendingListChanges = async () => {
|
||||
hasPendingListSync.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||
async () => {
|
||||
if (!autoRefreshEnabled.value) return
|
||||
if (document.hidden) return
|
||||
if (loading.value) return
|
||||
if (loading.value || autoRefreshFetching.value) return
|
||||
if (isAnyModalOpen.value) return
|
||||
if (menu.show) return
|
||||
if (inAutoRefreshSilentWindow()) {
|
||||
autoRefreshCountdown.value = Math.max(
|
||||
0,
|
||||
Math.ceil((autoRefreshSilentUntil.value - Date.now()) / 1000)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (autoRefreshCountdown.value <= 0) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
try {
|
||||
await load()
|
||||
} catch (e) {
|
||||
console.error('Auto refresh failed:', e)
|
||||
}
|
||||
await refreshAccountsIncrementally()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -695,6 +863,66 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
||||
}
|
||||
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
|
||||
const handleDataImported = () => { showImportData.value = false; reload() }
|
||||
const accountMatchesCurrentFilters = (account: Account) => {
|
||||
if (params.platform && account.platform !== params.platform) return false
|
||||
if (params.type && account.type !== params.type) return false
|
||||
if (params.status) {
|
||||
if (params.status === 'rate_limited') {
|
||||
if (!account.rate_limit_reset_at) return false
|
||||
const resetAt = new Date(account.rate_limit_reset_at).getTime()
|
||||
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false
|
||||
} else if (account.status !== params.status) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const search = String(params.search || '').trim().toLowerCase()
|
||||
if (search && !account.name.toLowerCase().includes(search)) return false
|
||||
return true
|
||||
}
|
||||
const mergeRuntimeFields = (oldAccount: Account, updatedAccount: Account): Account => ({
|
||||
...updatedAccount,
|
||||
current_concurrency: updatedAccount.current_concurrency ?? oldAccount.current_concurrency,
|
||||
current_window_cost: updatedAccount.current_window_cost ?? oldAccount.current_window_cost,
|
||||
active_sessions: updatedAccount.active_sessions ?? oldAccount.active_sessions
|
||||
})
|
||||
|
||||
const syncPaginationAfterLocalRemoval = () => {
|
||||
const nextTotal = Math.max(0, pagination.total - 1)
|
||||
pagination.total = nextTotal
|
||||
pagination.pages = nextTotal > 0 ? Math.ceil(nextTotal / pagination.page_size) : 0
|
||||
|
||||
const maxPage = Math.max(1, pagination.pages || 1)
|
||||
|
||||
if (pagination.page > maxPage) {
|
||||
pagination.page = maxPage
|
||||
}
|
||||
// 行被本地移除后不立刻全量补页,改为提示用户手动同步。
|
||||
hasPendingListSync.value = nextTotal > 0
|
||||
}
|
||||
|
||||
const patchAccountInList = (updatedAccount: Account) => {
|
||||
const index = accounts.value.findIndex(account => account.id === updatedAccount.id)
|
||||
if (index === -1) return
|
||||
const mergedAccount = mergeRuntimeFields(accounts.value[index], updatedAccount)
|
||||
if (!accountMatchesCurrentFilters(mergedAccount)) {
|
||||
accounts.value = accounts.value.filter(account => account.id !== mergedAccount.id)
|
||||
syncPaginationAfterLocalRemoval()
|
||||
selIds.value = selIds.value.filter(id => id !== mergedAccount.id)
|
||||
if (menu.acc?.id === mergedAccount.id) {
|
||||
menu.show = false
|
||||
menu.acc = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const nextAccounts = [...accounts.value]
|
||||
nextAccounts[index] = mergedAccount
|
||||
accounts.value = nextAccounts
|
||||
syncAccountRefs(mergedAccount)
|
||||
}
|
||||
const handleAccountUpdated = (updatedAccount: Account) => {
|
||||
patchAccountInList(updatedAccount)
|
||||
enterAutoRefreshSilentWindow()
|
||||
}
|
||||
const formatExportTimestamp = () => {
|
||||
const now = new Date()
|
||||
const pad2 = (value: number) => String(value).padStart(2, '0')
|
||||
@@ -744,9 +972,35 @@ const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = nul
|
||||
const handleTest = (a: Account) => { testingAcc.value = a; showTest.value = true }
|
||||
const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value = true }
|
||||
const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true }
|
||||
const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch (error) { console.error('Failed to refresh credentials:', error) } }
|
||||
const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to reset status:', error) } }
|
||||
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to clear rate limit:', error) } }
|
||||
const handleRefresh = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.refreshCredentials(a.id)
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh credentials:', error)
|
||||
}
|
||||
}
|
||||
const handleResetStatus = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.clearError(a.id)
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
appStore.showSuccess(t('common.success'))
|
||||
} catch (error) {
|
||||
console.error('Failed to reset status:', error)
|
||||
}
|
||||
}
|
||||
const handleClearRateLimit = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.clearRateLimit(a.id)
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
appStore.showSuccess(t('common.success'))
|
||||
} catch (error) {
|
||||
console.error('Failed to clear rate limit:', error)
|
||||
}
|
||||
}
|
||||
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
|
||||
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
|
||||
const handleToggleSchedulable = async (a: Account) => {
|
||||
@@ -755,6 +1009,7 @@ const handleToggleSchedulable = async (a: Account) => {
|
||||
try {
|
||||
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
|
||||
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
|
||||
enterAutoRefreshSilentWindow()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle schedulable:', error)
|
||||
appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
|
||||
@@ -763,7 +1018,18 @@ const handleToggleSchedulable = async (a: Account) => {
|
||||
}
|
||||
}
|
||||
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
|
||||
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
|
||||
const handleTempUnschedReset = async () => {
|
||||
if(!tempUnschedAcc.value) return
|
||||
try {
|
||||
const updated = await adminAPI.accounts.clearError(tempUnschedAcc.value.id)
|
||||
showTempUnsched.value = false
|
||||
tempUnschedAcc.value = null
|
||||
patchAccountInList(updated)
|
||||
enterAutoRefreshSilentWindow()
|
||||
} catch (error) {
|
||||
console.error('Failed to reset temp unscheduled:', error)
|
||||
}
|
||||
}
|
||||
const formatExpiresAt = (value: number | null) => {
|
||||
if (!value) return '-'
|
||||
return formatDateTime(
|
||||
|
||||
@@ -476,6 +476,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sora 按次计费配置 -->
|
||||
<div v-if="createForm.platform === 'sora'" class="border-t pt-4">
|
||||
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.soraPricing.title') }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.soraPricing.description') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image360') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_image_price_360"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.05"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image540') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_image_price_540"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.08"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.video') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_video_price_per_request"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.videoHd') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.sora_video_price_per_request_hd"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
@@ -701,8 +759,8 @@
|
||||
<!-- 路由规则列表(仅在启用时显示) -->
|
||||
<div v-if="createForm.model_routing_enabled" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in createModelRoutingRules"
|
||||
:key="index"
|
||||
v-for="rule in createModelRoutingRules"
|
||||
:key="getCreateRuleRenderKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -728,7 +786,7 @@
|
||||
{{ account.name }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeSelectedAccount(index, account.id, false)"
|
||||
@click="removeSelectedAccount(rule, account.id)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
@@ -738,23 +796,23 @@
|
||||
<!-- 账号搜索输入框 -->
|
||||
<div class="relative account-search-container">
|
||||
<input
|
||||
v-model="accountSearchKeyword[`create-${index}`]"
|
||||
v-model="accountSearchKeyword[getCreateRuleSearchKey(rule)]"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
|
||||
@input="searchAccounts(`create-${index}`)"
|
||||
@focus="onAccountSearchFocus(index, false)"
|
||||
@input="searchAccountsByRule(rule)"
|
||||
@focus="onAccountSearchFocus(rule)"
|
||||
/>
|
||||
<!-- 搜索结果下拉框 -->
|
||||
<div
|
||||
v-if="showAccountDropdown[`create-${index}`] && accountSearchResults[`create-${index}`]?.length > 0"
|
||||
v-if="showAccountDropdown[getCreateRuleSearchKey(rule)] && accountSearchResults[getCreateRuleSearchKey(rule)]?.length > 0"
|
||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="account in accountSearchResults[`create-${index}`]"
|
||||
v-for="account in accountSearchResults[getCreateRuleSearchKey(rule)]"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
@click="selectAccount(index, account, false)"
|
||||
@click="selectAccount(rule, account)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
|
||||
:disabled="rule.accounts.some(a => a.id === account.id)"
|
||||
@@ -769,7 +827,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeCreateRoutingRule(index)"
|
||||
@click="removeCreateRoutingRule(rule)"
|
||||
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
|
||||
:title="t('admin.groups.modelRouting.removeRule')"
|
||||
>
|
||||
@@ -1098,6 +1156,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sora 按次计费配置 -->
|
||||
<div v-if="editForm.platform === 'sora'" class="border-t pt-4">
|
||||
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.soraPricing.title') }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{{ t('admin.groups.soraPricing.description') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image360') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_image_price_360"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.05"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.image540') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_image_price_540"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.08"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.video') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_video_price_per_request"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.soraPricing.videoHd') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.sora_video_price_per_request_hd"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
@@ -1323,8 +1439,8 @@
|
||||
<!-- 路由规则列表(仅在启用时显示) -->
|
||||
<div v-if="editForm.model_routing_enabled" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in editModelRoutingRules"
|
||||
:key="index"
|
||||
v-for="rule in editModelRoutingRules"
|
||||
:key="getEditRuleRenderKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -1350,7 +1466,7 @@
|
||||
{{ account.name }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeSelectedAccount(index, account.id, true)"
|
||||
@click="removeSelectedAccount(rule, account.id, true)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
@@ -1360,23 +1476,23 @@
|
||||
<!-- 账号搜索输入框 -->
|
||||
<div class="relative account-search-container">
|
||||
<input
|
||||
v-model="accountSearchKeyword[`edit-${index}`]"
|
||||
v-model="accountSearchKeyword[getEditRuleSearchKey(rule)]"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
|
||||
@input="searchAccounts(`edit-${index}`)"
|
||||
@focus="onAccountSearchFocus(index, true)"
|
||||
@input="searchAccountsByRule(rule, true)"
|
||||
@focus="onAccountSearchFocus(rule, true)"
|
||||
/>
|
||||
<!-- 搜索结果下拉框 -->
|
||||
<div
|
||||
v-if="showAccountDropdown[`edit-${index}`] && accountSearchResults[`edit-${index}`]?.length > 0"
|
||||
v-if="showAccountDropdown[getEditRuleSearchKey(rule)] && accountSearchResults[getEditRuleSearchKey(rule)]?.length > 0"
|
||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="account in accountSearchResults[`edit-${index}`]"
|
||||
v-for="account in accountSearchResults[getEditRuleSearchKey(rule)]"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
@click="selectAccount(index, account, true)"
|
||||
@click="selectAccount(rule, account, true)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
|
||||
:disabled="rule.accounts.some(a => a.id === account.id)"
|
||||
@@ -1391,7 +1507,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeEditRoutingRule(index)"
|
||||
@click="removeEditRoutingRule(rule)"
|
||||
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
|
||||
:title="t('admin.groups.modelRouting.removeRule')"
|
||||
>
|
||||
@@ -1571,6 +1687,8 @@ import Select from '@/components/common/Select.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -1604,7 +1722,8 @@ const platformOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'sora', label: 'Sora' }
|
||||
])
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
@@ -1612,7 +1731,8 @@ const platformFilterOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'sora', label: 'Sora' }
|
||||
])
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
@@ -1756,6 +1876,11 @@ const createForm = reactive({
|
||||
image_price_1k: null as number | null,
|
||||
image_price_2k: null as number | null,
|
||||
image_price_4k: null as number | null,
|
||||
// Sora 按次计费配置
|
||||
sora_image_price_360: null as number | null,
|
||||
sora_image_price_540: null as number | null,
|
||||
sora_video_price_per_request: null as number | null,
|
||||
sora_video_price_per_request_hd: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -1788,33 +1913,70 @@ const createModelRoutingRules = ref<ModelRoutingRule[]>([])
|
||||
// 编辑表单的模型路由规则
|
||||
const editModelRoutingRules = ref<ModelRoutingRule[]>([])
|
||||
|
||||
// 账号搜索相关状态
|
||||
const accountSearchKeyword = ref<Record<string, string>>({}) // 每个规则的搜索关键词 (key: "create-0" 或 "edit-0")
|
||||
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({}) // 每个规则的搜索结果
|
||||
const showAccountDropdown = ref<Record<string, boolean>>({}) // 每个规则的下拉框显示状态
|
||||
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
// 规则对象稳定 key(避免使用 index 导致状态错位)
|
||||
const resolveCreateRuleKey = createStableObjectKeyResolver<ModelRoutingRule>('create-rule')
|
||||
const resolveEditRuleKey = createStableObjectKeyResolver<ModelRoutingRule>('edit-rule')
|
||||
|
||||
// 搜索账号(仅限 anthropic 平台)
|
||||
const searchAccounts = async (key: string) => {
|
||||
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
|
||||
accountSearchTimeout = setTimeout(async () => {
|
||||
const keyword = accountSearchKeyword.value[key] || ''
|
||||
try {
|
||||
const res = await adminAPI.accounts.list(1, 20, {
|
||||
const getCreateRuleRenderKey = (rule: ModelRoutingRule) => resolveCreateRuleKey(rule)
|
||||
const getEditRuleRenderKey = (rule: ModelRoutingRule) => resolveEditRuleKey(rule)
|
||||
|
||||
const getCreateRuleSearchKey = (rule: ModelRoutingRule) => `create-${resolveCreateRuleKey(rule)}`
|
||||
const getEditRuleSearchKey = (rule: ModelRoutingRule) => `edit-${resolveEditRuleKey(rule)}`
|
||||
|
||||
const getRuleSearchKey = (rule: ModelRoutingRule, isEdit: boolean = false) => {
|
||||
return isEdit ? getEditRuleSearchKey(rule) : getCreateRuleSearchKey(rule)
|
||||
}
|
||||
|
||||
// 账号搜索相关状态
|
||||
const accountSearchKeyword = ref<Record<string, string>>({})
|
||||
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({})
|
||||
const showAccountDropdown = ref<Record<string, boolean>>({})
|
||||
|
||||
const clearAccountSearchStateByKey = (key: string) => {
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
const clearAllAccountSearchState = () => {
|
||||
accountSearchKeyword.value = {}
|
||||
accountSearchResults.value = {}
|
||||
showAccountDropdown.value = {}
|
||||
}
|
||||
|
||||
const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
|
||||
delay: 300,
|
||||
search: async (keyword, { signal }) => {
|
||||
const res = await adminAPI.accounts.list(
|
||||
1,
|
||||
20,
|
||||
{
|
||||
search: keyword,
|
||||
platform: 'anthropic'
|
||||
})
|
||||
accountSearchResults.value[key] = res.items.map((a) => ({ id: a.id, name: a.name }))
|
||||
} catch {
|
||||
accountSearchResults.value[key] = []
|
||||
}
|
||||
}, 300)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
return res.items.map((account) => ({ id: account.id, name: account.name }))
|
||||
},
|
||||
onSuccess: (key, result) => {
|
||||
accountSearchResults.value[key] = result
|
||||
},
|
||||
onError: (key) => {
|
||||
accountSearchResults.value[key] = []
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索账号(仅限 anthropic 平台)
|
||||
const searchAccounts = (key: string) => {
|
||||
accountSearchRunner.trigger(key, accountSearchKeyword.value[key] || '')
|
||||
}
|
||||
|
||||
const searchAccountsByRule = (rule: ModelRoutingRule, isEdit: boolean = false) => {
|
||||
searchAccounts(getRuleSearchKey(rule, isEdit))
|
||||
}
|
||||
|
||||
// 选择账号
|
||||
const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolean = false) => {
|
||||
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
|
||||
const rule = rules[ruleIndex]
|
||||
const selectAccount = (rule: ModelRoutingRule, account: SimpleAccount, isEdit: boolean = false) => {
|
||||
if (!rule) return
|
||||
|
||||
// 检查是否已选择
|
||||
@@ -1823,15 +1985,13 @@ const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolea
|
||||
}
|
||||
|
||||
// 清空搜索
|
||||
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
|
||||
const key = getRuleSearchKey(rule, isEdit)
|
||||
accountSearchKeyword.value[key] = ''
|
||||
showAccountDropdown.value[key] = false
|
||||
}
|
||||
|
||||
// 移除已选账号
|
||||
const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boolean = false) => {
|
||||
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
|
||||
const rule = rules[ruleIndex]
|
||||
const removeSelectedAccount = (rule: ModelRoutingRule, accountId: number, _isEdit: boolean = false) => {
|
||||
if (!rule) return
|
||||
|
||||
rule.accounts = rule.accounts.filter(a => a.id !== accountId)
|
||||
@@ -1858,8 +2018,8 @@ const toggleEditScope = (scope: string) => {
|
||||
}
|
||||
|
||||
// 处理账号搜索输入框聚焦
|
||||
const onAccountSearchFocus = (ruleIndex: number, isEdit: boolean = false) => {
|
||||
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
|
||||
const onAccountSearchFocus = (rule: ModelRoutingRule, isEdit: boolean = false) => {
|
||||
const key = getRuleSearchKey(rule, isEdit)
|
||||
showAccountDropdown.value[key] = true
|
||||
// 如果没有搜索结果,触发一次搜索
|
||||
if (!accountSearchResults.value[key]?.length) {
|
||||
@@ -1873,13 +2033,14 @@ const addCreateRoutingRule = () => {
|
||||
}
|
||||
|
||||
// 删除创建表单的路由规则
|
||||
const removeCreateRoutingRule = (index: number) => {
|
||||
const removeCreateRoutingRule = (rule: ModelRoutingRule) => {
|
||||
const index = createModelRoutingRules.value.indexOf(rule)
|
||||
if (index === -1) return
|
||||
|
||||
const key = getCreateRuleSearchKey(rule)
|
||||
accountSearchRunner.clearKey(key)
|
||||
clearAccountSearchStateByKey(key)
|
||||
createModelRoutingRules.value.splice(index, 1)
|
||||
// 清理相关的搜索状态
|
||||
const key = `create-${index}`
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
// 添加编辑表单的路由规则
|
||||
@@ -1888,13 +2049,14 @@ const addEditRoutingRule = () => {
|
||||
}
|
||||
|
||||
// 删除编辑表单的路由规则
|
||||
const removeEditRoutingRule = (index: number) => {
|
||||
const removeEditRoutingRule = (rule: ModelRoutingRule) => {
|
||||
const index = editModelRoutingRules.value.indexOf(rule)
|
||||
if (index === -1) return
|
||||
|
||||
const key = getEditRuleSearchKey(rule)
|
||||
accountSearchRunner.clearKey(key)
|
||||
clearAccountSearchStateByKey(key)
|
||||
editModelRoutingRules.value.splice(index, 1)
|
||||
// 清理相关的搜索状态
|
||||
const key = `edit-${index}`
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
// 将 UI 格式的路由规则转换为 API 格式
|
||||
@@ -1954,6 +2116,11 @@ const editForm = reactive({
|
||||
image_price_1k: null as number | null,
|
||||
image_price_2k: null as number | null,
|
||||
image_price_4k: null as number | null,
|
||||
// Sora 按次计费配置
|
||||
sora_image_price_360: null as number | null,
|
||||
sora_image_price_540: null as number | null,
|
||||
sora_video_price_per_request: null as number | null,
|
||||
sora_video_price_per_request_hd: null as number | null,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -2033,6 +2200,10 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createModelRoutingRules.value.forEach((rule) => {
|
||||
accountSearchRunner.clearKey(getCreateRuleSearchKey(rule))
|
||||
})
|
||||
clearAllAccountSearchState()
|
||||
createForm.name = ''
|
||||
createForm.description = ''
|
||||
createForm.platform = 'anthropic'
|
||||
@@ -2045,6 +2216,10 @@ const closeCreateModal = () => {
|
||||
createForm.image_price_1k = null
|
||||
createForm.image_price_2k = null
|
||||
createForm.image_price_4k = null
|
||||
createForm.sora_image_price_360 = null
|
||||
createForm.sora_image_price_540 = null
|
||||
createForm.sora_video_price_per_request = null
|
||||
createForm.sora_video_price_per_request_hd = null
|
||||
createForm.claude_code_only = false
|
||||
createForm.fallback_group_id = null
|
||||
createForm.fallback_group_id_on_invalid_request = null
|
||||
@@ -2098,6 +2273,10 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.image_price_1k = group.image_price_1k
|
||||
editForm.image_price_2k = group.image_price_2k
|
||||
editForm.image_price_4k = group.image_price_4k
|
||||
editForm.sora_image_price_360 = group.sora_image_price_360
|
||||
editForm.sora_image_price_540 = group.sora_image_price_540
|
||||
editForm.sora_video_price_per_request = group.sora_video_price_per_request
|
||||
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
|
||||
editForm.claude_code_only = group.claude_code_only || false
|
||||
editForm.fallback_group_id = group.fallback_group_id
|
||||
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
|
||||
@@ -2111,6 +2290,10 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
editModelRoutingRules.value.forEach((rule) => {
|
||||
accountSearchRunner.clearKey(getEditRuleSearchKey(rule))
|
||||
})
|
||||
clearAllAccountSearchState()
|
||||
showEditModal.value = false
|
||||
editingGroup.value = null
|
||||
editModelRoutingRules.value = []
|
||||
@@ -2246,5 +2429,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
accountSearchRunner.clearAll()
|
||||
clearAllAccountSearchState()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
<Icon name="play" size="md" class="mr-2" />
|
||||
{{ t('admin.proxies.testConnection') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleBatchQualityCheck"
|
||||
:disabled="batchQualityChecking || loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.proxies.batchQualityCheck')"
|
||||
>
|
||||
<Icon name="shield" size="md" class="mr-2" :class="batchQualityChecking ? 'animate-pulse' : ''" />
|
||||
{{ t('admin.proxies.batchQualityCheck') }}
|
||||
</button>
|
||||
<button
|
||||
@click="openBatchDelete"
|
||||
:disabled="selectedCount === 0"
|
||||
@@ -151,20 +160,32 @@
|
||||
</template>
|
||||
|
||||
<template #cell-latency="{ row }">
|
||||
<span
|
||||
v-if="row.latency_status === 'failed'"
|
||||
class="badge badge-danger"
|
||||
:title="row.latency_message || undefined"
|
||||
>
|
||||
{{ t('admin.proxies.latencyFailed') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="typeof row.latency_ms === 'number'"
|
||||
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
|
||||
>
|
||||
{{ row.latency_ms }}ms
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
v-if="row.latency_status === 'failed'"
|
||||
class="badge badge-danger"
|
||||
:title="row.latency_message || undefined"
|
||||
>
|
||||
{{ t('admin.proxies.latencyFailed') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="typeof row.latency_ms === 'number'"
|
||||
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
|
||||
>
|
||||
{{ row.latency_ms }}ms
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
<div
|
||||
v-if="typeof row.quality_checked === 'number'"
|
||||
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
:title="row.quality_summary || undefined"
|
||||
>
|
||||
<span>{{ t('admin.proxies.qualityInline', { grade: row.quality_grade || '-', score: row.quality_score ?? '-' }) }}</span>
|
||||
<span class="badge" :class="qualityOverallClass(row.quality_status)">
|
||||
{{ qualityOverallLabel(row.quality_status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
@@ -203,6 +224,34 @@
|
||||
<Icon v-else name="checkCircle" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.proxies.testConnection') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleQualityCheck(row)"
|
||||
:disabled="qualityCheckingProxyIds.has(row.id)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
>
|
||||
<svg
|
||||
v-if="qualityCheckingProxyIds.has(row.id)"
|
||||
class="h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="shield" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.proxies.qualityCheck') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||
@@ -623,6 +672,82 @@
|
||||
@imported="handleDataImported"
|
||||
/>
|
||||
|
||||
<BaseDialog
|
||||
:show="showQualityReportDialog"
|
||||
:title="t('admin.proxies.qualityReportTitle')"
|
||||
width="normal"
|
||||
@close="closeQualityReportDialog"
|
||||
>
|
||||
<div v-if="qualityReport" class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ qualityReportProxy?.name || '-' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ qualityReport.summary }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ qualityReport.score }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.proxies.qualityGrade', { grade: qualityReport.grade }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>{{ t('admin.proxies.qualityExitIP') }}: {{ qualityReport.exit_ip || '-' }}</div>
|
||||
<div>{{ t('admin.proxies.qualityCountry') }}: {{ qualityReport.country || '-' }}</div>
|
||||
<div>
|
||||
{{ t('admin.proxies.qualityBaseLatency') }}:
|
||||
{{ typeof qualityReport.base_latency_ms === 'number' ? `${qualityReport.base_latency_ms}ms` : '-' }}
|
||||
</div>
|
||||
<div>{{ t('admin.proxies.qualityCheckedAt') }}: {{ new Date(qualityReport.checked_at * 1000).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableTarget') }}</th>
|
||||
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableStatus') }}</th>
|
||||
<th class="px-3 py-2 text-left">HTTP</th>
|
||||
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableLatency') }}</th>
|
||||
<th class="px-3 py-2 text-left">{{ t('admin.proxies.qualityTableMessage') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<tr v-for="item in qualityReport.items" :key="item.target">
|
||||
<td class="px-3 py-2 text-gray-900 dark:text-white">{{ qualityTargetLabel(item.target) }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge" :class="qualityStatusClass(item.status)">{{ qualityStatusLabel(item.status) }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{{ item.http_status ?? '-' }}</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
{{ typeof item.latency_ms === 'number' ? `${item.latency_ms}ms` : '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
<span>{{ item.message || '-' }}</span>
|
||||
<span v-if="item.cf_ray" class="ml-1 text-xs text-gray-400">(cf-ray: {{ item.cf_ray }})</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button @click="closeQualityReportDialog" class="btn btn-secondary">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Proxy Accounts Dialog -->
|
||||
<BaseDialog
|
||||
:show="showAccountsModal"
|
||||
@@ -675,7 +800,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, ProxyAccountSummary, ProxyProtocol } from '@/types'
|
||||
import type { Proxy, ProxyAccountSummary, ProxyProtocol, ProxyQualityCheckResult } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -756,13 +881,18 @@ const showAccountsModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const exportingData = ref(false)
|
||||
const testingProxyIds = ref<Set<number>>(new Set())
|
||||
const qualityCheckingProxyIds = ref<Set<number>>(new Set())
|
||||
const batchTesting = ref(false)
|
||||
const batchQualityChecking = ref(false)
|
||||
const selectedProxyIds = ref<Set<number>>(new Set())
|
||||
const accountsProxy = ref<Proxy | null>(null)
|
||||
const proxyAccounts = ref<ProxyAccountSummary[]>([])
|
||||
const accountsLoading = ref(false)
|
||||
const editingProxy = ref<Proxy | null>(null)
|
||||
const deletingProxy = ref<Proxy | null>(null)
|
||||
const showQualityReportDialog = ref(false)
|
||||
const qualityReportProxy = ref<Proxy | null>(null)
|
||||
const qualityReport = ref<ProxyQualityCheckResult | null>(null)
|
||||
|
||||
const selectedCount = computed(() => selectedProxyIds.value.size)
|
||||
const allVisibleSelected = computed(() => {
|
||||
@@ -1132,6 +1262,23 @@ const applyLatencyResult = (
|
||||
target.latency_message = result.message
|
||||
}
|
||||
|
||||
const summarizeQualityStatus = (result: ProxyQualityCheckResult): Proxy['quality_status'] => {
|
||||
if (result.challenge_count > 0) return 'challenge'
|
||||
if (result.failed_count > 0) return 'failed'
|
||||
if (result.warn_count > 0) return 'warn'
|
||||
return 'healthy'
|
||||
}
|
||||
|
||||
const applyQualityResult = (proxyId: number, result: ProxyQualityCheckResult) => {
|
||||
const target = proxies.value.find((proxy) => proxy.id === proxyId)
|
||||
if (!target) return
|
||||
target.quality_status = summarizeQualityStatus(result)
|
||||
target.quality_score = result.score
|
||||
target.quality_grade = result.grade
|
||||
target.quality_summary = result.summary
|
||||
target.quality_checked = result.checked_at
|
||||
}
|
||||
|
||||
const formatLocation = (proxy: Proxy) => {
|
||||
const parts = [proxy.country, proxy.city].filter(Boolean) as string[]
|
||||
return parts.join(' · ')
|
||||
@@ -1150,6 +1297,16 @@ const stopTestingProxy = (proxyId: number) => {
|
||||
testingProxyIds.value = next
|
||||
}
|
||||
|
||||
const startQualityCheckingProxy = (proxyId: number) => {
|
||||
qualityCheckingProxyIds.value = new Set([...qualityCheckingProxyIds.value, proxyId])
|
||||
}
|
||||
|
||||
const stopQualityCheckingProxy = (proxyId: number) => {
|
||||
const next = new Set(qualityCheckingProxyIds.value)
|
||||
next.delete(proxyId)
|
||||
qualityCheckingProxyIds.value = next
|
||||
}
|
||||
|
||||
const runProxyTest = async (proxyId: number, notify: boolean) => {
|
||||
startTestingProxy(proxyId)
|
||||
try {
|
||||
@@ -1183,6 +1340,150 @@ const handleTestConnection = async (proxy: Proxy) => {
|
||||
await runProxyTest(proxy.id, true)
|
||||
}
|
||||
|
||||
const handleQualityCheck = async (proxy: Proxy) => {
|
||||
startQualityCheckingProxy(proxy.id)
|
||||
try {
|
||||
const result = await adminAPI.proxies.checkProxyQuality(proxy.id)
|
||||
qualityReportProxy.value = proxy
|
||||
qualityReport.value = result
|
||||
showQualityReportDialog.value = true
|
||||
|
||||
const baseStep = result.items.find((item) => item.target === 'base_connectivity')
|
||||
if (baseStep && baseStep.status === 'pass') {
|
||||
applyLatencyResult(proxy.id, {
|
||||
success: true,
|
||||
latency_ms: result.base_latency_ms,
|
||||
message: result.summary,
|
||||
ip_address: result.exit_ip,
|
||||
country: result.country,
|
||||
country_code: result.country_code
|
||||
})
|
||||
}
|
||||
applyQualityResult(proxy.id, result)
|
||||
|
||||
appStore.showSuccess(
|
||||
t('admin.proxies.qualityCheckDone', { score: result.score, grade: result.grade })
|
||||
)
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.detail || t('admin.proxies.qualityCheckFailed')
|
||||
appStore.showError(message)
|
||||
console.error('Error checking proxy quality:', error)
|
||||
} finally {
|
||||
stopQualityCheckingProxy(proxy.id)
|
||||
}
|
||||
}
|
||||
|
||||
const runBatchProxyQualityChecks = async (ids: number[]) => {
|
||||
if (ids.length === 0) return { total: 0, healthy: 0, warn: 0, challenge: 0, failed: 0 }
|
||||
|
||||
const concurrency = 3
|
||||
let index = 0
|
||||
let healthy = 0
|
||||
let warn = 0
|
||||
let challenge = 0
|
||||
let failed = 0
|
||||
|
||||
const worker = async () => {
|
||||
while (index < ids.length) {
|
||||
const current = ids[index]
|
||||
index++
|
||||
startQualityCheckingProxy(current)
|
||||
try {
|
||||
const result = await adminAPI.proxies.checkProxyQuality(current)
|
||||
const target = proxies.value.find((proxy) => proxy.id === current)
|
||||
if (target) {
|
||||
const baseStep = result.items.find((item) => item.target === 'base_connectivity')
|
||||
if (baseStep && baseStep.status === 'pass') {
|
||||
applyLatencyResult(current, {
|
||||
success: true,
|
||||
latency_ms: result.base_latency_ms,
|
||||
message: result.summary,
|
||||
ip_address: result.exit_ip,
|
||||
country: result.country,
|
||||
country_code: result.country_code
|
||||
})
|
||||
}
|
||||
}
|
||||
applyQualityResult(current, result)
|
||||
if (result.challenge_count > 0) {
|
||||
challenge++
|
||||
} else if (result.failed_count > 0) {
|
||||
failed++
|
||||
} else if (result.warn_count > 0) {
|
||||
warn++
|
||||
} else {
|
||||
healthy++
|
||||
}
|
||||
} catch {
|
||||
failed++
|
||||
} finally {
|
||||
stopQualityCheckingProxy(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
return {
|
||||
total: ids.length,
|
||||
healthy,
|
||||
warn,
|
||||
challenge,
|
||||
failed
|
||||
}
|
||||
}
|
||||
|
||||
const closeQualityReportDialog = () => {
|
||||
showQualityReportDialog.value = false
|
||||
qualityReportProxy.value = null
|
||||
qualityReport.value = null
|
||||
}
|
||||
|
||||
const qualityStatusClass = (status: string) => {
|
||||
if (status === 'pass') return 'badge-success'
|
||||
if (status === 'warn') return 'badge-warning'
|
||||
if (status === 'challenge') return 'badge-danger'
|
||||
return 'badge-danger'
|
||||
}
|
||||
|
||||
const qualityStatusLabel = (status: string) => {
|
||||
if (status === 'pass') return t('admin.proxies.qualityStatusPass')
|
||||
if (status === 'warn') return t('admin.proxies.qualityStatusWarn')
|
||||
if (status === 'challenge') return t('admin.proxies.qualityStatusChallenge')
|
||||
return t('admin.proxies.qualityStatusFail')
|
||||
}
|
||||
|
||||
const qualityOverallClass = (status?: string) => {
|
||||
if (status === 'healthy') return 'badge-success'
|
||||
if (status === 'warn') return 'badge-warning'
|
||||
if (status === 'challenge') return 'badge-danger'
|
||||
return 'badge-danger'
|
||||
}
|
||||
|
||||
const qualityOverallLabel = (status?: string) => {
|
||||
if (status === 'healthy') return t('admin.proxies.qualityStatusHealthy')
|
||||
if (status === 'warn') return t('admin.proxies.qualityStatusWarn')
|
||||
if (status === 'challenge') return t('admin.proxies.qualityStatusChallenge')
|
||||
return t('admin.proxies.qualityStatusFail')
|
||||
}
|
||||
|
||||
const qualityTargetLabel = (target: string) => {
|
||||
switch (target) {
|
||||
case 'base_connectivity':
|
||||
return t('admin.proxies.qualityTargetBase')
|
||||
case 'openai':
|
||||
return 'OpenAI'
|
||||
case 'anthropic':
|
||||
return 'Anthropic'
|
||||
case 'gemini':
|
||||
return 'Gemini'
|
||||
case 'sora':
|
||||
return 'Sora'
|
||||
default:
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
|
||||
const pageSize = 200
|
||||
const result: Proxy[] = []
|
||||
@@ -1253,6 +1554,43 @@ const handleBatchTest = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchQualityCheck = async () => {
|
||||
if (batchQualityChecking.value) return
|
||||
|
||||
batchQualityChecking.value = true
|
||||
try {
|
||||
let ids: number[] = []
|
||||
if (selectedCount.value > 0) {
|
||||
ids = Array.from(selectedProxyIds.value)
|
||||
} else {
|
||||
const allProxies = await fetchAllProxiesForBatch()
|
||||
ids = allProxies.map((proxy) => proxy.id)
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
appStore.showInfo(t('admin.proxies.batchQualityEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const summary = await runBatchProxyQualityChecks(ids)
|
||||
appStore.showSuccess(
|
||||
t('admin.proxies.batchQualityDone', {
|
||||
count: summary.total,
|
||||
healthy: summary.healthy,
|
||||
warn: summary.warn,
|
||||
challenge: summary.challenge,
|
||||
failed: summary.failed
|
||||
})
|
||||
)
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchQualityFailed'))
|
||||
console.error('Error batch checking quality:', error)
|
||||
} finally {
|
||||
batchQualityChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatExportTimestamp = () => {
|
||||
const now = new Date()
|
||||
const pad2 = (value: number) => String(value).padStart(2, '0')
|
||||
|
||||
@@ -94,57 +94,44 @@ const exportToExcel = async () => {
|
||||
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
||||
const c = new AbortController(); exportAbortController = c
|
||||
try {
|
||||
const all: AdminUsageLog[] = []; let p = 1; let total = pagination.total
|
||||
let p = 1; let total = pagination.total; let exportedCount = 0
|
||||
const XLSX = await import('xlsx')
|
||||
const headers = [
|
||||
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
|
||||
t('usage.type'),
|
||||
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
|
||||
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
|
||||
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
|
||||
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
|
||||
t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
|
||||
]
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers])
|
||||
while (true) {
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
if (res.items?.length) all.push(...res.items)
|
||||
exportProgress.current = all.length; exportProgress.progress = total > 0 ? Math.min(100, Math.round(all.length/total*100)) : 0
|
||||
if (all.length >= total || res.items.length < 100) break; p++
|
||||
const rows = (res.items || []).map((log: AdminUsageLog) => [
|
||||
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
|
||||
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', log.stream ? t('usage.stream') : t('usage.sync'),
|
||||
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
|
||||
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
|
||||
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
|
||||
log.rate_multiplier?.toFixed(2) || '1.00', (log.account_rate_multiplier ?? 1).toFixed(2),
|
||||
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
|
||||
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
|
||||
log.request_id || '', log.user_agent || '', log.ip_address || ''
|
||||
])
|
||||
if (rows.length) {
|
||||
XLSX.utils.sheet_add_aoa(ws, rows, { origin: -1 })
|
||||
}
|
||||
exportedCount += rows.length
|
||||
exportProgress.current = exportedCount
|
||||
exportProgress.progress = total > 0 ? Math.min(100, Math.round(exportedCount / total * 100)) : 0
|
||||
if (exportedCount >= total || res.items.length < 100) break; p++
|
||||
}
|
||||
if(!c.signal.aborted) {
|
||||
const XLSX = await import('xlsx')
|
||||
const headers = [
|
||||
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
|
||||
t('usage.type'),
|
||||
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
|
||||
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
|
||||
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
|
||||
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
|
||||
t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
|
||||
]
|
||||
const rows = all.map(log => [
|
||||
log.created_at,
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.account?.name || '',
|
||||
log.model,
|
||||
formatReasoningEffort(log.reasoning_effort),
|
||||
log.group?.name || '',
|
||||
log.stream ? t('usage.stream') : t('usage.sync'),
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
log.input_cost?.toFixed(6) || '0.000000',
|
||||
log.output_cost?.toFixed(6) || '0.000000',
|
||||
log.cache_read_cost?.toFixed(6) || '0.000000',
|
||||
log.cache_creation_cost?.toFixed(6) || '0.000000',
|
||||
log.rate_multiplier?.toFixed(2) || '1.00',
|
||||
(log.account_rate_multiplier ?? 1).toFixed(2),
|
||||
log.total_cost?.toFixed(6) || '0.000000',
|
||||
log.actual_cost?.toFixed(6) || '0.000000',
|
||||
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6),
|
||||
log.first_token_ms ?? '',
|
||||
log.duration_ms,
|
||||
log.request_id || '',
|
||||
log.user_agent || '',
|
||||
log.ip_address || ''
|
||||
])
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Usage')
|
||||
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`)
|
||||
|
||||
@@ -84,9 +84,25 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Row: OpenAI Token Stats -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6">
|
||||
<OpsOpenAITokenStatsCard
|
||||
:platform-filter="platform"
|
||||
:group-id-filter="groupId"
|
||||
:refresh-token="dashboardRefreshToken"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Alert Events -->
|
||||
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
||||
|
||||
<!-- System Logs -->
|
||||
<OpsSystemLogTable
|
||||
v-if="opsEnabled && !(loading && !hasLoadedOnce)"
|
||||
:platform-filter="platform"
|
||||
:refresh-token="dashboardRefreshToken"
|
||||
/>
|
||||
|
||||
<!-- Settings Dialog (hidden in fullscreen mode) -->
|
||||
<template v-if="!isFullscreen">
|
||||
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
|
||||
@@ -148,6 +164,8 @@ import OpsLatencyChart from './components/OpsLatencyChart.vue'
|
||||
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
|
||||
import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue'
|
||||
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
|
||||
import OpsOpenAITokenStatsCard from './components/OpsOpenAITokenStatsCard.vue'
|
||||
import OpsSystemLogTable from './components/OpsSystemLogTable.vue'
|
||||
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
|
||||
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
|
||||
import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue'
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { opsAPI, type OpsOpenAITokenStatsResponse, type OpsOpenAITokenStatsTimeRange } from '@/api/admin/ops'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
platformFilter?: string
|
||||
groupIdFilter?: number | null
|
||||
refreshToken: number
|
||||
}
|
||||
|
||||
type ViewMode = 'topn' | 'pagination'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
platformFilter: '',
|
||||
groupIdFilter: null
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const response = ref<OpsOpenAITokenStatsResponse | null>(null)
|
||||
|
||||
const timeRange = ref<OpsOpenAITokenStatsTimeRange>('30d')
|
||||
const viewMode = ref<ViewMode>('topn')
|
||||
const topN = ref<number>(20)
|
||||
const page = ref<number>(1)
|
||||
const pageSize = ref<number>(20)
|
||||
|
||||
const items = computed(() => response.value?.items ?? [])
|
||||
const total = computed(() => response.value?.total ?? 0)
|
||||
const totalPages = computed(() => {
|
||||
if (viewMode.value !== 'pagination') return 1
|
||||
const size = pageSize.value > 0 ? pageSize.value : 20
|
||||
return Math.max(1, Math.ceil(total.value / size))
|
||||
})
|
||||
|
||||
const timeRangeOptions = computed(() => [
|
||||
{ value: '30m', label: t('admin.ops.timeRange.30m') },
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '1d', label: t('admin.ops.timeRange.1d') },
|
||||
{ value: '15d', label: t('admin.ops.timeRange.15d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') }
|
||||
])
|
||||
|
||||
const viewModeOptions = computed(() => [
|
||||
{ value: 'topn', label: t('admin.ops.openaiTokenStats.viewModeTopN') },
|
||||
{ value: 'pagination', label: t('admin.ops.openaiTokenStats.viewModePagination') }
|
||||
])
|
||||
|
||||
const topNOptions = computed(() => [
|
||||
{ value: 10, label: 'Top 10' },
|
||||
{ value: 20, label: 'Top 20' },
|
||||
{ value: 50, label: 'Top 50' },
|
||||
{ value: 100, label: 'Top 100' }
|
||||
])
|
||||
|
||||
const pageSizeOptions = computed(() => [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: '100' }
|
||||
])
|
||||
|
||||
function formatRate(v?: number | null): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return '-'
|
||||
return v.toFixed(2)
|
||||
}
|
||||
|
||||
function formatInt(v?: number | null): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return '-'
|
||||
return formatNumber(Math.round(v))
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange.value,
|
||||
platform: props.platformFilter || undefined,
|
||||
group_id: typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0 ? props.groupIdFilter : undefined
|
||||
}
|
||||
|
||||
if (viewMode.value === 'topn') {
|
||||
params.top_n = topN.value
|
||||
} else {
|
||||
params.page = page.value
|
||||
params.page_size = pageSize.value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
response.value = await opsAPI.getOpenAITokenStats(buildParams())
|
||||
// 防御:若 total 变化导致当前页超出最大页,则回退到末页并重新拉取一次。
|
||||
if (viewMode.value === 'pagination' && page.value > totalPages.value) {
|
||||
page.value = totalPages.value
|
||||
response.value = await opsAPI.getOpenAITokenStats(buildParams())
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[OpsOpenAITokenStatsCard] Failed to load data', err)
|
||||
response.value = null
|
||||
errorMessage.value = err?.message || t('admin.ops.openaiTokenStats.failedToLoad')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
timeRange: timeRange.value,
|
||||
viewMode: viewMode.value,
|
||||
topN: topN.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
platform: props.platformFilter,
|
||||
groupId: props.groupIdFilter,
|
||||
refreshToken: props.refreshToken
|
||||
}),
|
||||
(next, prev) => {
|
||||
// 避免“筛选变化 -> 重置页码 -> 触发两次请求”:
|
||||
// 先只重置页码,等待下一次 watch(仅 page 变化)再发起请求。
|
||||
const filtersChanged = !prev ||
|
||||
next.timeRange !== prev.timeRange ||
|
||||
next.viewMode !== prev.viewMode ||
|
||||
next.pageSize !== prev.pageSize ||
|
||||
next.platform !== prev.platform ||
|
||||
next.groupId !== prev.groupId
|
||||
|
||||
if (next.viewMode === 'pagination' && filtersChanged && next.page !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
void loadData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onPrevPage() {
|
||||
if (viewMode.value !== 'pagination') return
|
||||
if (page.value > 1) page.value -= 1
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (viewMode.value !== 'pagination') return
|
||||
if (page.value < totalPages.value) page.value += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card p-4 md:p-5">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.openaiTokenStats.title') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="w-36">
|
||||
<Select v-model="timeRange" :options="timeRangeOptions" />
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<Select v-model="viewMode" :options="viewModeOptions" />
|
||||
</div>
|
||||
<div v-if="viewMode === 'topn'" class="w-28">
|
||||
<Select v-model="topN" :options="topNOptions" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="w-24">
|
||||
<Select v-model="pageSize" :options="pageSizeOptions" />
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loading || page <= 1"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
{{ t('admin.ops.openaiTokenStats.prevPage') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loading || page >= totalPages"
|
||||
@click="onNextPage"
|
||||
>
|
||||
{{ t('admin.ops.openaiTokenStats.nextPage') }}
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.openaiTokenStats.pageInfo', { page, total: totalPages }) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="mb-4 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.loadingText') }}
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="items.length === 0"
|
||||
:title="t('common.noData')"
|
||||
:description="t('admin.ops.openaiTokenStats.empty')"
|
||||
/>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in items"
|
||||
:key="row.model"
|
||||
class="border-b border-gray-100 text-gray-700 dark:border-dark-800 dark:text-gray-200"
|
||||
>
|
||||
<td class="px-2 py-2 font-medium">{{ row.model }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.request_count) }}</td>
|
||||
<td class="px-2 py-2">{{ formatRate(row.avg_tokens_per_sec) }}</td>
|
||||
<td class="px-2 py-2">{{ formatRate(row.avg_first_token_ms) }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.total_output_tokens) }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.avg_duration_ms) }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.requests_with_first_token) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="viewMode === 'topn'" class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.openaiTokenStats.totalModels', { total }) }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
506
frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
Normal file
506
frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { opsAPI, type OpsRuntimeLogConfig, type OpsSystemLog, type OpsSystemLogSinkHealth } from '@/api/admin/ops'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
platformFilter?: string
|
||||
refreshToken?: number
|
||||
}>(), {
|
||||
platformFilter: '',
|
||||
refreshToken: 0
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<OpsSystemLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const health = ref<OpsSystemLogSinkHealth>({
|
||||
queue_depth: 0,
|
||||
queue_capacity: 0,
|
||||
dropped_count: 0,
|
||||
write_failed_count: 0,
|
||||
written_count: 0,
|
||||
avg_write_delay_ms: 0
|
||||
})
|
||||
|
||||
const runtimeLoading = ref(false)
|
||||
const runtimeSaving = ref(false)
|
||||
const runtimeConfig = reactive<OpsRuntimeLogConfig>({
|
||||
level: 'info',
|
||||
enable_sampling: false,
|
||||
sampling_initial: 100,
|
||||
sampling_thereafter: 100,
|
||||
caller: true,
|
||||
stacktrace_level: 'error',
|
||||
retention_days: 30
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
time_range: '1h' as '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
level: '',
|
||||
component: '',
|
||||
request_id: '',
|
||||
client_request_id: '',
|
||||
user_id: '',
|
||||
account_id: '',
|
||||
platform: '',
|
||||
model: '',
|
||||
q: ''
|
||||
})
|
||||
|
||||
const levelBadgeClass = (level: string) => {
|
||||
const v = String(level || '').toLowerCase()
|
||||
if (v === 'error' || v === 'fatal') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (v === 'warn' || v === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
if (v === 'debug') return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
}
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
const getExtraString = (extra: Record<string, any> | undefined, key: string) => {
|
||||
if (!extra) return ''
|
||||
const v = extra[key]
|
||||
if (v == null) return ''
|
||||
if (typeof v === 'string') return v.trim()
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatSystemLogDetail = (row: OpsSystemLog) => {
|
||||
const parts: string[] = []
|
||||
const msg = String(row.message || '').trim()
|
||||
if (msg) parts.push(msg)
|
||||
|
||||
const extra = row.extra || {}
|
||||
const statusCode = getExtraString(extra, 'status_code')
|
||||
const latencyMs = getExtraString(extra, 'latency_ms')
|
||||
const method = getExtraString(extra, 'method')
|
||||
const path = getExtraString(extra, 'path')
|
||||
const clientIP = getExtraString(extra, 'client_ip')
|
||||
const protocol = getExtraString(extra, 'protocol')
|
||||
|
||||
const accessParts: string[] = []
|
||||
if (statusCode) accessParts.push(`status=${statusCode}`)
|
||||
if (latencyMs) accessParts.push(`latency_ms=${latencyMs}`)
|
||||
if (method) accessParts.push(`method=${method}`)
|
||||
if (path) accessParts.push(`path=${path}`)
|
||||
if (clientIP) accessParts.push(`ip=${clientIP}`)
|
||||
if (protocol) accessParts.push(`proto=${protocol}`)
|
||||
if (accessParts.length > 0) parts.push(accessParts.join(' '))
|
||||
|
||||
const corrParts: string[] = []
|
||||
if (row.request_id) corrParts.push(`req=${row.request_id}`)
|
||||
if (row.client_request_id) corrParts.push(`client_req=${row.client_request_id}`)
|
||||
if (row.user_id != null) corrParts.push(`user=${row.user_id}`)
|
||||
if (row.account_id != null) corrParts.push(`acc=${row.account_id}`)
|
||||
if (row.platform) corrParts.push(`platform=${row.platform}`)
|
||||
if (row.model) corrParts.push(`model=${row.model}`)
|
||||
if (corrParts.length > 0) parts.push(corrParts.join(' '))
|
||||
|
||||
const errors = getExtraString(extra, 'errors')
|
||||
if (errors) parts.push(`errors=${errors}`)
|
||||
const err = getExtraString(extra, 'err') || getExtraString(extra, 'error')
|
||||
if (err) parts.push(`error=${err}`)
|
||||
|
||||
// 用空格拼接,交给 CSS 自动换行,尽量“填满再换行”。
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const toRFC3339 = (value: string) => {
|
||||
if (!value) return undefined
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return undefined
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
const buildQuery = () => {
|
||||
const query: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
time_range: filters.time_range
|
||||
}
|
||||
|
||||
if (filters.time_range === '30d') {
|
||||
query.time_range = '30d'
|
||||
}
|
||||
if (filters.start_time) query.start_time = toRFC3339(filters.start_time)
|
||||
if (filters.end_time) query.end_time = toRFC3339(filters.end_time)
|
||||
if (filters.level.trim()) query.level = filters.level.trim()
|
||||
if (filters.component.trim()) query.component = filters.component.trim()
|
||||
if (filters.request_id.trim()) query.request_id = filters.request_id.trim()
|
||||
if (filters.client_request_id.trim()) query.client_request_id = filters.client_request_id.trim()
|
||||
if (filters.user_id.trim()) {
|
||||
const v = Number.parseInt(filters.user_id.trim(), 10)
|
||||
if (Number.isFinite(v) && v > 0) query.user_id = v
|
||||
}
|
||||
if (filters.account_id.trim()) {
|
||||
const v = Number.parseInt(filters.account_id.trim(), 10)
|
||||
if (Number.isFinite(v) && v > 0) query.account_id = v
|
||||
}
|
||||
if (filters.platform.trim()) query.platform = filters.platform.trim()
|
||||
if (filters.model.trim()) query.model = filters.model.trim()
|
||||
if (filters.q.trim()) query.q = filters.q.trim()
|
||||
return query
|
||||
}
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await opsAPI.listSystemLogs(buildQuery())
|
||||
logs.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to fetch logs', err)
|
||||
appStore.showError(err?.response?.data?.detail || '系统日志加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchHealth = async () => {
|
||||
try {
|
||||
health.value = await opsAPI.getSystemLogSinkHealth()
|
||||
} catch {
|
||||
// 忽略健康数据读取失败,不影响主流程。
|
||||
}
|
||||
}
|
||||
|
||||
const loadRuntimeConfig = async () => {
|
||||
runtimeLoading.value = true
|
||||
try {
|
||||
const cfg = await opsAPI.getRuntimeLogConfig()
|
||||
runtimeConfig.level = cfg.level
|
||||
runtimeConfig.enable_sampling = cfg.enable_sampling
|
||||
runtimeConfig.sampling_initial = cfg.sampling_initial
|
||||
runtimeConfig.sampling_thereafter = cfg.sampling_thereafter
|
||||
runtimeConfig.caller = cfg.caller
|
||||
runtimeConfig.stacktrace_level = cfg.stacktrace_level
|
||||
runtimeConfig.retention_days = cfg.retention_days
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to load runtime log config', err)
|
||||
} finally {
|
||||
runtimeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveRuntimeConfig = async () => {
|
||||
runtimeSaving.value = true
|
||||
try {
|
||||
const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig })
|
||||
runtimeConfig.level = saved.level
|
||||
runtimeConfig.enable_sampling = saved.enable_sampling
|
||||
runtimeConfig.sampling_initial = saved.sampling_initial
|
||||
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
|
||||
runtimeConfig.caller = saved.caller
|
||||
runtimeConfig.stacktrace_level = saved.stacktrace_level
|
||||
runtimeConfig.retention_days = saved.retention_days
|
||||
appStore.showSuccess('日志运行时配置已生效')
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to save runtime log config', err)
|
||||
appStore.showError(err?.response?.data?.detail || '保存日志配置失败')
|
||||
} finally {
|
||||
runtimeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetRuntimeConfig = async () => {
|
||||
const ok = window.confirm('确认回滚为启动配置(env/yaml)并立即生效?')
|
||||
if (!ok) return
|
||||
|
||||
runtimeSaving.value = true
|
||||
try {
|
||||
const saved = await opsAPI.resetRuntimeLogConfig()
|
||||
runtimeConfig.level = saved.level
|
||||
runtimeConfig.enable_sampling = saved.enable_sampling
|
||||
runtimeConfig.sampling_initial = saved.sampling_initial
|
||||
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
|
||||
runtimeConfig.caller = saved.caller
|
||||
runtimeConfig.stacktrace_level = saved.stacktrace_level
|
||||
runtimeConfig.retention_days = saved.retention_days
|
||||
appStore.showSuccess('已回滚到启动日志配置')
|
||||
await fetchHealth()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to reset runtime log config', err)
|
||||
appStore.showError(err?.response?.data?.detail || '回滚日志配置失败')
|
||||
} finally {
|
||||
runtimeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupCurrentFilter = async () => {
|
||||
const ok = window.confirm('确认按当前筛选条件清理系统日志?该操作不可撤销。')
|
||||
if (!ok) return
|
||||
try {
|
||||
const payload = {
|
||||
start_time: toRFC3339(filters.start_time),
|
||||
end_time: toRFC3339(filters.end_time),
|
||||
level: filters.level.trim() || undefined,
|
||||
component: filters.component.trim() || undefined,
|
||||
request_id: filters.request_id.trim() || undefined,
|
||||
client_request_id: filters.client_request_id.trim() || undefined,
|
||||
user_id: filters.user_id.trim() ? Number.parseInt(filters.user_id.trim(), 10) : undefined,
|
||||
account_id: filters.account_id.trim() ? Number.parseInt(filters.account_id.trim(), 10) : undefined,
|
||||
platform: filters.platform.trim() || undefined,
|
||||
model: filters.model.trim() || undefined,
|
||||
q: filters.q.trim() || undefined
|
||||
}
|
||||
const res = await opsAPI.cleanupSystemLogs(payload)
|
||||
appStore.showSuccess(`清理完成,删除 ${res.deleted || 0} 条日志`)
|
||||
page.value = 1
|
||||
await Promise.all([fetchLogs(), fetchHealth()])
|
||||
} catch (err: any) {
|
||||
console.error('[OpsSystemLogTable] Failed to cleanup logs', err)
|
||||
appStore.showError(err?.response?.data?.detail || '清理系统日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.time_range = '1h'
|
||||
filters.start_time = ''
|
||||
filters.end_time = ''
|
||||
filters.level = ''
|
||||
filters.component = ''
|
||||
filters.request_id = ''
|
||||
filters.client_request_id = ''
|
||||
filters.user_id = ''
|
||||
filters.account_id = ''
|
||||
filters.platform = props.platformFilter || ''
|
||||
filters.model = ''
|
||||
filters.q = ''
|
||||
page.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
watch(() => props.platformFilter, (v) => {
|
||||
if (v && !filters.platform) {
|
||||
filters.platform = v
|
||||
page.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.refreshToken, () => {
|
||||
fetchLogs()
|
||||
fetchHealth()
|
||||
})
|
||||
|
||||
const onPageChange = (next: number) => {
|
||||
page.value = next
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const onPageSizeChange = (next: number) => {
|
||||
pageSize.value = next
|
||||
page.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
page.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const hasData = computed(() => logs.value.length > 0)
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.platformFilter) {
|
||||
filters.platform = props.platformFilter
|
||||
}
|
||||
await Promise.all([fetchLogs(), fetchHealth(), loadRuntimeConfig()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900/60">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">系统日志</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">默认按最新时间倒序,支持筛选搜索与按条件清理。</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">队列 {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
|
||||
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">写入 {{ health.written_count }}</span>
|
||||
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">丢弃 {{ health.dropped_count }}</span>
|
||||
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">失败 {{ health.write_failed_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800/70">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">运行时日志配置(实时生效)</div>
|
||||
<span v-if="runtimeLoading" class="text-xs text-gray-500">加载中...</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-6">
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
级别
|
||||
<select v-model="runtimeConfig.level" class="input mt-1">
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
堆栈阈值
|
||||
<select v-model="runtimeConfig.stacktrace_level" class="input mt-1">
|
||||
<option value="none">none</option>
|
||||
<option value="error">error</option>
|
||||
<option value="fatal">fatal</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
采样初始
|
||||
<input v-model.number="runtimeConfig.sampling_initial" type="number" min="1" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
采样后续
|
||||
<input v-model.number="runtimeConfig.sampling_thereafter" type="number" min="1" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
保留天数
|
||||
<input v-model.number="runtimeConfig.retention_days" type="number" min="1" max="3650" class="input mt-1" />
|
||||
</label>
|
||||
<div class="flex items-end gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input v-model="runtimeConfig.caller" type="checkbox" />
|
||||
caller
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input v-model="runtimeConfig.enable_sampling" type="checkbox" />
|
||||
sampling
|
||||
</label>
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="runtimeSaving" @click="saveRuntimeConfig">
|
||||
{{ runtimeSaving ? '保存中...' : '保存并生效' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
|
||||
回滚默认值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">最近写入错误:{{ health.last_error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-5">
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
时间范围
|
||||
<select v-model="filters.time_range" class="input mt-1">
|
||||
<option value="5m">5m</option>
|
||||
<option value="30m">30m</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="6h">6h</option>
|
||||
<option value="24h">24h</option>
|
||||
<option value="7d">7d</option>
|
||||
<option value="30d">30d</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
开始时间(可选)
|
||||
<input v-model="filters.start_time" type="datetime-local" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
结束时间(可选)
|
||||
<input v-model="filters.end_time" type="datetime-local" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
级别
|
||||
<select v-model="filters.level" class="input mt-1">
|
||||
<option value="">全部</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
组件
|
||||
<input v-model="filters.component" type="text" class="input mt-1" placeholder="如 http.access" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
request_id
|
||||
<input v-model="filters.request_id" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
client_request_id
|
||||
<input v-model="filters.client_request_id" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
user_id
|
||||
<input v-model="filters.user_id" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
account_id
|
||||
<input v-model="filters.account_id" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
平台
|
||||
<input v-model="filters.platform" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
模型
|
||||
<input v-model="filters.model" type="text" class="input mt-1" />
|
||||
</label>
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
关键词
|
||||
<input v-model="filters.q" type="text" class="input mt-1" placeholder="消息/request_id" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="applyFilters">查询</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">按当前筛选清理</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">刷新健康指标</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">加载中...</div>
|
||||
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">暂无系统日志</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<table class="min-w-full table-fixed divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="w-[170px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">时间</th>
|
||||
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">级别</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">日志详细信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<tr v-for="row in logs" :key="row.id" class="align-top">
|
||||
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{{ formatTime(row.created_at) }}</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 font-semibold" :class="levelBadgeClass(row.level)">
|
||||
{{ row.level }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300 whitespace-normal break-all">
|
||||
{{ formatSystemLogDetail(row) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50, 100, 200]"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import OpsOpenAITokenStatsCard from '../OpsOpenAITokenStatsCard.vue'
|
||||
|
||||
const mockGetOpenAITokenStats = vi.fn()
|
||||
|
||||
vi.mock('@/api/admin/ops', () => ({
|
||||
opsAPI: {
|
||||
getOpenAITokenStats: (...args: any[]) => mockGetOpenAITokenStats(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, any>) => {
|
||||
if (key === 'admin.ops.openaiTokenStats.pageInfo' && params) {
|
||||
return `第 ${params.page}/${params.total} 页`
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'SelectControlStub',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div class="select-stub" />',
|
||||
})
|
||||
|
||||
const EmptyStateStub = defineComponent({
|
||||
name: 'EmptyState',
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
},
|
||||
template: '<div class="empty-state">{{ title }}|{{ description }}</div>',
|
||||
})
|
||||
|
||||
const sampleResponse = {
|
||||
time_range: '30d' as const,
|
||||
start_time: '2026-01-01T00:00:00Z',
|
||||
end_time: '2026-01-31T00:00:00Z',
|
||||
platform: 'openai',
|
||||
group_id: 7,
|
||||
items: [
|
||||
{
|
||||
model: 'gpt-4o-mini',
|
||||
request_count: 12,
|
||||
avg_tokens_per_sec: 22.5,
|
||||
avg_first_token_ms: 123.45,
|
||||
total_output_tokens: 1234,
|
||||
avg_duration_ms: 321,
|
||||
requests_with_first_token: 10,
|
||||
},
|
||||
],
|
||||
total: 40,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
top_n: null,
|
||||
}
|
||||
|
||||
describe('OpsOpenAITokenStatsCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('默认加载并透传 platform/group 过滤,支持时间窗口切换', async () => {
|
||||
mockGetOpenAITokenStats.mockResolvedValue(sampleResponse)
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: {
|
||||
platformFilter: 'openai',
|
||||
groupIdFilter: 7,
|
||||
refreshToken: 0,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
time_range: '30d',
|
||||
platform: 'openai',
|
||||
group_id: 7,
|
||||
top_n: 20,
|
||||
})
|
||||
)
|
||||
|
||||
const selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[0].vm.$emit('update:modelValue', '1h')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
time_range: '1h',
|
||||
platform: 'openai',
|
||||
group_id: 7,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('支持分页与 TopN 模式切换并按参数请求', async () => {
|
||||
mockGetOpenAITokenStats.mockImplementation(async (params: Record<string, any>) => ({
|
||||
...sampleResponse,
|
||||
time_range: params.time_range ?? '30d',
|
||||
page: params.page ?? 1,
|
||||
page_size: params.page_size ?? 20,
|
||||
top_n: params.top_n ?? null,
|
||||
total: 40,
|
||||
}))
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: {
|
||||
refreshToken: 0,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
let selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[1].vm.$emit('update:modelValue', 'pagination')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
await buttons[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 2,
|
||||
page_size: 20,
|
||||
})
|
||||
)
|
||||
|
||||
selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[1].vm.$emit('update:modelValue', 'topn')
|
||||
await flushPromises()
|
||||
selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[2].vm.$emit('update:modelValue', 50)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
top_n: 50,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('接口返回空数据时显示空态', async () => {
|
||||
mockGetOpenAITokenStats.mockResolvedValue({
|
||||
...sampleResponse,
|
||||
items: [],
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: { refreshToken: 0 },
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.empty-state').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('接口异常时显示错误提示', async () => {
|
||||
mockGetOpenAITokenStats.mockRejectedValue(new Error('加载失败'))
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: { refreshToken: 0 },
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('加载失败')
|
||||
})
|
||||
})
|
||||
@@ -17,5 +17,8 @@ export type {
|
||||
OpsMetricThresholds,
|
||||
OpsAdvancedSettings,
|
||||
OpsDataRetentionSettings,
|
||||
OpsAggregationSettings
|
||||
OpsAggregationSettings,
|
||||
OpsRuntimeLogConfig,
|
||||
OpsSystemLog,
|
||||
OpsSystemLogSinkHealth
|
||||
} from '@/api/admin/ops'
|
||||
|
||||
@@ -159,6 +159,13 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-last_used_at="{ value }">
|
||||
<span v-if="value" class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ formatDateTime(value) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
@@ -738,6 +745,7 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'usage', label: t('keys.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
||||
{ key: 'status', label: t('common.status'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true },
|
||||
{ key: 'created_at', label: t('keys.created'), sortable: true },
|
||||
{ key: 'actions', label: t('common.actions'), sortable: false }
|
||||
])
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-user_agent="{ row }">
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] whitespace-normal break-all" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
@@ -574,16 +574,7 @@ const formatDuration = (ms: number): string => {
|
||||
}
|
||||
|
||||
const formatUserAgent = (ua: string): string => {
|
||||
// 提取主要客户端标识
|
||||
if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI'
|
||||
if (ua.includes('Cursor')) return 'Cursor'
|
||||
if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code'
|
||||
if (ua.includes('Continue')) return 'Continue'
|
||||
if (ua.includes('Cline')) return 'Cline'
|
||||
if (ua.includes('OpenAI')) return 'OpenAI SDK'
|
||||
if (ua.includes('anthropic')) return 'Anthropic SDK'
|
||||
// 截断过长的 UA
|
||||
return ua.length > 30 ? ua.substring(0, 30) + '...' : ua
|
||||
return ua
|
||||
}
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.{js,ts,vue}'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'src/**/*.d.ts',
|
||||
'src/**/*.spec.ts',
|
||||
'src/**/*.test.ts',
|
||||
'src/main.ts'
|
||||
],
|
||||
thresholds: {
|
||||
global: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80
|
||||
}
|
||||
}
|
||||
},
|
||||
setupFiles: ['./src/__tests__/setup.ts']
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
define: {
|
||||
__INTLIFY_JIT_COMPILATION__: true
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.{js,ts,vue}'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'src/**/*.d.ts',
|
||||
'src/**/*.spec.ts',
|
||||
'src/**/*.test.ts',
|
||||
'src/main.ts'
|
||||
],
|
||||
thresholds: {
|
||||
global: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80
|
||||
}
|
||||
}
|
||||
},
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
testTimeout: 10000
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user