Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	frontend/src/components/layout/AuthLayout.vue
This commit is contained in:
huangzhenpc
2026-03-08 17:56:51 +08:00
855 changed files with 165096 additions and 42854 deletions

View File

@@ -17,7 +17,7 @@
"dependencies": {
"@lobehub/icons": "^4.0.2",
"@vueuse/core": "^10.7.0",
"axios": "^1.6.2",
"axios": "^1.13.5",
"chart.js": "^4.4.1",
"dompurify": "^3.3.1",
"driver.js": "^1.4.0",
@@ -27,6 +27,7 @@
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-chartjs": "^5.3.0",
"vue-draggable-plus": "^0.6.1",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"

View File

@@ -15,8 +15,8 @@ importers:
specifier: ^10.7.0
version: 10.11.1(vue@3.5.26(typescript@5.6.3))
axios:
specifier: ^1.6.2
version: 1.13.2
specifier: ^1.13.5
version: 1.13.5
chart.js:
specifier: ^4.4.1
version: 4.5.1
@@ -44,6 +44,9 @@ importers:
vue-chartjs:
specifier: ^5.3.0
version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3))
vue-draggable-plus:
specifier: ^0.6.1
version: 0.6.1(@types/sortablejs@1.15.9)
vue-i18n:
specifier: ^9.14.5
version: 9.14.5(vue@3.5.26(typescript@5.6.3))
@@ -1515,6 +1518,9 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -1810,8 +1816,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
axios@1.13.5:
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
@@ -4298,6 +4304,15 @@ packages:
'@vue/composition-api':
optional: true
vue-draggable-plus@0.6.1:
resolution: {integrity: sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==}
peerDependencies:
'@types/sortablejs': ^1.15.0
'@vue/composition-api': '*'
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -5958,6 +5973,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/sortablejs@1.15.9': {}
'@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {}
@@ -6381,7 +6398,7 @@ snapshots:
postcss: 8.5.6
postcss-value-parser: 4.2.0
axios@1.13.2:
axios@1.13.5:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
@@ -9401,6 +9418,10 @@ snapshots:
dependencies:
vue: 3.5.26(typescript@5.6.3)
vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9):
dependencies:
'@types/sortablejs': 1.15.9
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.3

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { RouterView, useRouter, useRoute } from 'vue-router'
import { onMounted, watch } from 'vue'
import { onMounted, onBeforeUnmount, watch } from 'vue'
import Toast from '@/components/common/Toast.vue'
import NavigationProgress from '@/components/common/NavigationProgress.vue'
import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
const router = useRouter()
@@ -11,6 +12,7 @@ const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const announcementStore = useAnnouncementStore()
/**
* Update favicon dynamically
@@ -39,34 +41,55 @@ 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 + announcements
function onVisibilityChange() {
if (document.visibilityState === 'visible' && authStore.isAuthenticated) {
announcementStore.fetchAnnouncements()
}
}
// Watch for authentication state and manage subscription data
watch(
() => authStore.isAuthenticated,
(isAuthenticated) => {
(isAuthenticated, oldValue) => {
if (isAuthenticated) {
// User logged in: preload subscriptions and start polling
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to preload subscriptions:', error)
})
subscriptionStore.startPolling()
// Announcements: new login vs page refresh restore
if (oldValue === false) {
// New login: delay 3s then force fetch
setTimeout(() => announcementStore.fetchAnnouncements(true), 3000)
} else {
// Page refresh restore (oldValue was undefined)
announcementStore.fetchAnnouncements()
}
// Register visibility change listener
document.addEventListener('visibilitychange', onVisibilityChange)
} else {
// User logged out: clear data and stop polling
subscriptionStore.clear()
announcementStore.reset()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
},
{ immediate: true }
)
// Route change trigger (throttled by store)
router.afterEach(() => {
if (authStore.isAuthenticated) {
announcementStore.fetchAnnouncements()
}
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange)
})
onMounted(async () => {
// Check if setup is needed
try {
@@ -88,4 +111,5 @@ onMounted(async () => {
<NavigationProgress />
<RouterView />
<Toast />
<AnnouncementPopup />
</template>

View File

@@ -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')
})

View File

@@ -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')
})

View 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()
})
})
})

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import {
normalizeGenerationListResponse,
normalizeModelFamiliesResponse
} from '../sora'
describe('sora api normalizers', () => {
it('normalizes generation list from data shape', () => {
const result = normalizeGenerationListResponse({
data: [{ id: 1, status: 'pending' }],
total: 9,
page: 2
})
expect(result.data).toHaveLength(1)
expect(result.total).toBe(9)
expect(result.page).toBe(2)
})
it('normalizes generation list from items shape', () => {
const result = normalizeGenerationListResponse({
items: [{ id: 1, status: 'completed' }],
total: 1
})
expect(result.data).toHaveLength(1)
expect(result.total).toBe(1)
expect(result.page).toBe(1)
})
it('falls back to empty generation list on invalid payload', () => {
const result = normalizeGenerationListResponse(null)
expect(result).toEqual({ data: [], total: 0, page: 1 })
})
it('normalizes family model payload', () => {
const result = normalizeModelFamiliesResponse({
data: [
{
id: 'sora2',
name: 'Sora 2',
type: 'video',
orientations: ['landscape', 'portrait'],
durations: [10, 15]
}
]
})
expect(result).toHaveLength(1)
expect(result[0].id).toBe('sora2')
expect(result[0].orientations).toEqual(['landscape', 'portrait'])
expect(result[0].durations).toEqual([10, 15])
})
it('normalizes legacy flat model list into families', () => {
const result = normalizeModelFamiliesResponse({
items: [
{ id: 'sora2-landscape-10s', type: 'video' },
{ id: 'sora2-portrait-15s', type: 'video' },
{ id: 'gpt-image-square', type: 'image' }
]
})
const sora2 = result.find((m) => m.id === 'sora2')
expect(sora2).toBeTruthy()
expect(sora2?.orientations).toEqual(['landscape', 'portrait'])
expect(sora2?.durations).toEqual([10, 15])
const image = result.find((m) => m.id === 'gpt-image')
expect(image).toBeTruthy()
expect(image?.type).toBe('image')
expect(image?.orientations).toEqual(['square'])
})
it('falls back to empty families on invalid payload', () => {
expect(normalizeModelFamiliesResponse(undefined)).toEqual([])
expect(normalizeModelFamiliesResponse({})).toEqual([])
})
})

View File

@@ -15,7 +15,9 @@ import type {
AccountUsageStatsResponse,
TempUnschedulableStatus,
AdminDataPayload,
AdminDataImportResult
AdminDataImportResult,
CheckMixedChannelRequest,
CheckMixedChannelResponse
} from '@/types'
/**
@@ -32,7 +34,9 @@ export async function list(
platform?: string
type?: string
status?: string
group?: string
search?: string
lite?: string
},
options?: {
signal?: AbortSignal
@@ -49,6 +53,59 @@ 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
lite?: 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
@@ -80,6 +137,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
return data
}
/**
* Check mixed-channel risk for account-group binding.
*/
export async function checkMixedChannelRisk(
payload: CheckMixedChannelRequest
): Promise<CheckMixedChannelResponse> {
const { data } = await apiClient.post<CheckMixedChannelResponse>('/admin/accounts/check-mixed-channel', payload)
return data
}
/**
* Delete account
* @param id - Account ID
@@ -164,15 +231,27 @@ 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
}
/**
* Reset account quota usage
* @param id - Account ID
* @returns Updated account
*/
export async function resetAccountQuota(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(
`/admin/accounts/${id}/reset-quota`
)
return data
}
/**
* Get temporary unschedulable status
* @param id - Account ID
@@ -219,7 +298,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
@@ -304,6 +383,22 @@ export async function getTodayStats(id: number): Promise<WindowStats> {
return data
}
export interface BatchTodayStatsResponse {
stats: Record<string, WindowStats>
}
/**
* 批量获取多个账号的今日统计
* @param accountIds - 账号 ID 列表
* @returns 以账号 ID字符串为键的统计映射
*/
export async function getBatchTodayStats(accountIds: number[]): Promise<BatchTodayStatsResponse> {
const { data } = await apiClient.post<BatchTodayStatsResponse>('/admin/accounts/today-stats/batch', {
account_ids: accountIds
})
return data
}
/**
* Set account schedulable status
* @param id - Account ID
@@ -327,11 +422,34 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
return data
}
export interface CRSPreviewAccount {
crs_account_id: string
kind: string
name: string
platform: string
type: string
}
export interface PreviewFromCRSResult {
new_accounts: CRSPreviewAccount[]
existing_accounts: CRSPreviewAccount[]
}
export async function previewFromCrs(params: {
base_url: string
username: string
password: string
}): Promise<PreviewFromCRSResult> {
const { data } = await apiClient.post<PreviewFromCRSResult>('/admin/accounts/sync/crs/preview', params)
return data
}
export async function syncFromCrs(params: {
base_url: string
username: string
password: string
sync_proxies?: boolean
selected_account_ids?: string[]
}): Promise<{
created: number
updated: number
@@ -345,7 +463,19 @@ export async function syncFromCrs(params: {
error?: string
}>
}> {
const { data } = await apiClient.post('/admin/accounts/sync/crs', params)
const { data } = await apiClient.post<{
created: number
updated: number
skipped: number
failed: number
items: Array<{
crs_account_id: string
kind: string
name: string
action: string
error?: string
}>
}>('/admin/accounts/sync/crs', params)
return data
}
@@ -398,11 +528,56 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
return data
}
/**
* Refresh OpenAI token using refresh token
* @param refreshToken - The refresh token
* @param proxyId - Optional proxy ID
* @returns Token information including access_token, email, etc.
*/
export async function refreshOpenAIToken(
refreshToken: string,
proxyId?: number | null,
endpoint: string = '/admin/openai/refresh-token'
): Promise<Record<string, unknown>> {
const payload: { refresh_token: string; proxy_id?: number } = {
refresh_token: refreshToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
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,
checkMixedChannelRisk,
delete: deleteAccount,
toggleStatus,
testAccount,
@@ -411,16 +586,21 @@ export const accountsAPI = {
clearError,
getUsage,
getTodayStats,
getBatchTodayStats,
clearRateLimit,
resetAccountQuota,
getTempUnschedulableStatus,
resetTempUnschedulable,
setSchedulable,
getAvailableModels,
generateAuthUrl,
exchangeCode,
refreshOpenAIToken,
validateSoraSessionToken,
batchCreate,
batchUpdateCredentials,
bulkUpdate,
previewFromCrs,
syncFromCrs,
exportData,
importData,

View File

@@ -53,4 +53,18 @@ export async function exchangeCode(
return data
}
export default { generateAuthUrl, exchangeCode }
export async function refreshAntigravityToken(
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo> {
const payload: Record<string, any> = { refresh_token: refreshToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<AntigravityTokenInfo>(
'/admin/antigravity/oauth/refresh-token',
payload
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }

View File

@@ -0,0 +1,33 @@
/**
* Admin API Keys API endpoints
* Handles API key management for administrators
*/
import { apiClient } from '../client'
import type { ApiKey } from '@/types'
export interface UpdateApiKeyGroupResult {
api_key: ApiKey
auto_granted_group_access: boolean
granted_group_id?: number
granted_group_name?: string
}
/**
* Update an API key's group binding
* @param id - API Key ID
* @param groupId - Group ID (0 to unbind, positive to bind, null/undefined to skip)
* @returns Updated API key with auto-grant info
*/
export async function updateApiKeyGroup(id: number, groupId: number | null): Promise<UpdateApiKeyGroupResult> {
const { data } = await apiClient.put<UpdateApiKeyGroupResult>(`/admin/api-keys/${id}`, {
group_id: groupId === null ? 0 : groupId
})
return data
}
export const apiKeysAPI = {
updateApiKeyGroup
}
export default apiKeysAPI

View File

@@ -8,8 +8,10 @@ import type {
DashboardStats,
TrendDataPoint,
ModelStat,
GroupStat,
ApiKeyUsageTrendPoint,
UserUsageTrendPoint
UserUsageTrendPoint,
UsageRequestType
} from '@/types'
/**
@@ -49,6 +51,7 @@ export interface TrendParams {
model?: string
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
@@ -78,6 +81,7 @@ export interface ModelStatsParams {
model?: string
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
@@ -98,6 +102,69 @@ export async function getModelStats(params?: ModelStatsParams): Promise<ModelSta
return data
}
export interface GroupStatsParams {
start_date?: string
end_date?: string
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
export interface GroupStatsResponse {
groups: GroupStat[]
start_date: string
end_date: string
}
export interface DashboardSnapshotV2Params extends TrendParams {
include_stats?: boolean
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
include_users_trend?: boolean
users_trend_limit?: number
}
export interface DashboardSnapshotV2Stats extends DashboardStats {
uptime: number
}
export interface DashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
stats?: DashboardSnapshotV2Stats
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
users_trend?: UserUsageTrendPoint[]
}
/**
* Get group usage statistics
* @param params - Query parameters for filtering
* @returns Group usage statistics
*/
export async function getGroupStats(params?: GroupStatsParams): Promise<GroupStatsResponse> {
const { data } = await apiClient.get<GroupStatsResponse>('/admin/dashboard/groups', { params })
return data
}
/**
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
*/
export async function getSnapshotV2(params?: DashboardSnapshotV2Params): Promise<DashboardSnapshotV2Response> {
const { data } = await apiClient.get<DashboardSnapshotV2Response>('/admin/dashboard/snapshot-v2', {
params
})
return data
}
export interface ApiKeyTrendParams extends TrendParams {
limit?: number
}
@@ -200,6 +267,8 @@ export const dashboardAPI = {
getRealtimeMetrics,
getUsageTrend,
getModelStats,
getGroupStats,
getSnapshotV2,
getApiKeyUsageTrend,
getUserUsageTrend,
getBatchUsersUsage,

View File

@@ -0,0 +1,332 @@
import { apiClient } from '../client'
export type BackupType = 'postgres' | 'redis' | 'full'
export type BackupJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'partial_succeeded'
export interface BackupAgentInfo {
status: string
version: string
uptime_seconds: number
}
export interface BackupAgentHealth {
enabled: boolean
reason: string
socket_path: string
agent?: BackupAgentInfo
}
export interface DataManagementPostgresConfig {
host: string
port: number
user: string
password?: string
password_configured?: boolean
database: string
ssl_mode: string
container_name: string
}
export interface DataManagementRedisConfig {
addr: string
username: string
password?: string
password_configured?: boolean
db: number
container_name: string
}
export interface DataManagementS3Config {
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
secret_access_key_configured?: boolean
prefix: string
force_path_style: boolean
use_ssl: boolean
}
export interface DataManagementConfig {
source_mode: 'direct' | 'docker_exec'
backup_root: string
sqlite_path?: string
retention_days: number
keep_last: number
active_postgres_profile_id?: string
active_redis_profile_id?: string
active_s3_profile_id?: string
postgres: DataManagementPostgresConfig
redis: DataManagementRedisConfig
s3: DataManagementS3Config
}
export type SourceType = 'postgres' | 'redis'
export interface DataManagementSourceConfig {
host: string
port: number
user: string
password?: string
database: string
ssl_mode: string
addr: string
username: string
db: number
container_name: string
}
export interface DataManagementSourceProfile {
source_type: SourceType
profile_id: string
name: string
is_active: boolean
password_configured?: boolean
config: DataManagementSourceConfig
created_at?: string
updated_at?: string
}
export interface TestS3Request {
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key: string
prefix?: string
force_path_style?: boolean
use_ssl?: boolean
}
export interface TestS3Response {
ok: boolean
message: string
}
export interface CreateBackupJobRequest {
backup_type: BackupType
upload_to_s3?: boolean
s3_profile_id?: string
postgres_profile_id?: string
redis_profile_id?: string
idempotency_key?: string
}
export interface CreateBackupJobResponse {
job_id: string
status: BackupJobStatus
}
export interface BackupArtifactInfo {
local_path: string
size_bytes: number
sha256: string
}
export interface BackupS3Info {
bucket: string
key: string
etag: string
}
export interface BackupJob {
job_id: string
backup_type: BackupType
status: BackupJobStatus
triggered_by: string
s3_profile_id?: string
postgres_profile_id?: string
redis_profile_id?: string
started_at?: string
finished_at?: string
error_message?: string
artifact?: BackupArtifactInfo
s3?: BackupS3Info
}
export interface ListSourceProfilesResponse {
items: DataManagementSourceProfile[]
}
export interface CreateSourceProfileRequest {
profile_id: string
name: string
config: DataManagementSourceConfig
set_active?: boolean
}
export interface UpdateSourceProfileRequest {
name: string
config: DataManagementSourceConfig
}
export interface DataManagementS3Profile {
profile_id: string
name: string
is_active: boolean
s3: DataManagementS3Config
secret_access_key_configured?: boolean
created_at?: string
updated_at?: string
}
export interface ListS3ProfilesResponse {
items: DataManagementS3Profile[]
}
export interface CreateS3ProfileRequest {
profile_id: string
name: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix?: string
force_path_style?: boolean
use_ssl?: boolean
set_active?: boolean
}
export interface UpdateS3ProfileRequest {
name: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix?: string
force_path_style?: boolean
use_ssl?: boolean
}
export interface ListBackupJobsRequest {
page_size?: number
page_token?: string
status?: BackupJobStatus
backup_type?: BackupType
}
export interface ListBackupJobsResponse {
items: BackupJob[]
next_page_token?: string
}
export async function getAgentHealth(): Promise<BackupAgentHealth> {
const { data } = await apiClient.get<BackupAgentHealth>('/admin/data-management/agent/health')
return data
}
export async function getConfig(): Promise<DataManagementConfig> {
const { data } = await apiClient.get<DataManagementConfig>('/admin/data-management/config')
return data
}
export async function updateConfig(request: DataManagementConfig): Promise<DataManagementConfig> {
const { data } = await apiClient.put<DataManagementConfig>('/admin/data-management/config', request)
return data
}
export async function testS3(request: TestS3Request): Promise<TestS3Response> {
const { data } = await apiClient.post<TestS3Response>('/admin/data-management/s3/test', request)
return data
}
export async function listSourceProfiles(sourceType: SourceType): Promise<ListSourceProfilesResponse> {
const { data } = await apiClient.get<ListSourceProfilesResponse>(`/admin/data-management/sources/${sourceType}/profiles`)
return data
}
export async function createSourceProfile(sourceType: SourceType, request: CreateSourceProfileRequest): Promise<DataManagementSourceProfile> {
const { data } = await apiClient.post<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles`, request)
return data
}
export async function updateSourceProfile(sourceType: SourceType, profileID: string, request: UpdateSourceProfileRequest): Promise<DataManagementSourceProfile> {
const { data } = await apiClient.put<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles/${profileID}`, request)
return data
}
export async function deleteSourceProfile(sourceType: SourceType, profileID: string): Promise<void> {
await apiClient.delete(`/admin/data-management/sources/${sourceType}/profiles/${profileID}`)
}
export async function setActiveSourceProfile(sourceType: SourceType, profileID: string): Promise<DataManagementSourceProfile> {
const { data } = await apiClient.post<DataManagementSourceProfile>(`/admin/data-management/sources/${sourceType}/profiles/${profileID}/activate`)
return data
}
export async function listS3Profiles(): Promise<ListS3ProfilesResponse> {
const { data } = await apiClient.get<ListS3ProfilesResponse>('/admin/data-management/s3/profiles')
return data
}
export async function createS3Profile(request: CreateS3ProfileRequest): Promise<DataManagementS3Profile> {
const { data } = await apiClient.post<DataManagementS3Profile>('/admin/data-management/s3/profiles', request)
return data
}
export async function updateS3Profile(profileID: string, request: UpdateS3ProfileRequest): Promise<DataManagementS3Profile> {
const { data } = await apiClient.put<DataManagementS3Profile>(`/admin/data-management/s3/profiles/${profileID}`, request)
return data
}
export async function deleteS3Profile(profileID: string): Promise<void> {
await apiClient.delete(`/admin/data-management/s3/profiles/${profileID}`)
}
export async function setActiveS3Profile(profileID: string): Promise<DataManagementS3Profile> {
const { data } = await apiClient.post<DataManagementS3Profile>(`/admin/data-management/s3/profiles/${profileID}/activate`)
return data
}
export async function createBackupJob(request: CreateBackupJobRequest): Promise<CreateBackupJobResponse> {
const headers = request.idempotency_key
? { 'X-Idempotency-Key': request.idempotency_key }
: undefined
const { data } = await apiClient.post<CreateBackupJobResponse>(
'/admin/data-management/backups',
request,
{ headers }
)
return data
}
export async function listBackupJobs(request?: ListBackupJobsRequest): Promise<ListBackupJobsResponse> {
const { data } = await apiClient.get<ListBackupJobsResponse>('/admin/data-management/backups', {
params: request
})
return data
}
export async function getBackupJob(jobID: string): Promise<BackupJob> {
const { data } = await apiClient.get<BackupJob>(`/admin/data-management/backups/${jobID}`)
return data
}
export const dataManagementAPI = {
getAgentHealth,
getConfig,
updateConfig,
listSourceProfiles,
createSourceProfile,
updateSourceProfile,
deleteSourceProfile,
setActiveSourceProfile,
testS3,
listS3Profiles,
createS3Profile,
updateS3Profile,
deleteS3Profile,
setActiveS3Profile,
createBackupJob,
listBackupJobs,
getBackupJob
}
export default dataManagementAPI

View File

@@ -21,6 +21,7 @@ export interface ErrorPassthroughRule {
response_code: number | null
passthrough_body: boolean
custom_message: string | null
skip_monitoring: boolean
description: string | null
created_at: string
updated_at: string
@@ -41,6 +42,7 @@ export interface CreateRuleRequest {
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
skip_monitoring?: boolean
description?: string | null
}
@@ -59,6 +61,7 @@ export interface UpdateRuleRequest {
response_code?: number | null
passthrough_body?: boolean
custom_message?: string | null
skip_monitoring?: boolean
description?: string | null
}

View File

@@ -153,6 +153,20 @@ export async function getGroupApiKeys(
return data
}
/**
* Update group sort orders
* @param updates - Array of { id, sort_order } objects
* @returns Success confirmation
*/
export async function updateSortOrder(
updates: Array<{ id: number; sort_order: number }>
): Promise<{ message: string }> {
const { data } = await apiClient.put<{ message: string }>('/admin/groups/sort-order', {
updates
})
return data
}
export const groupsAPI = {
list,
getAll,
@@ -163,7 +177,8 @@ export const groupsAPI = {
delete: deleteGroup,
toggleStatus,
getStats,
getGroupApiKeys
getGroupApiKeys,
updateSortOrder
}
export default groupsAPI

View File

@@ -20,6 +20,9 @@ import antigravityAPI from './antigravity'
import userAttributesAPI from './userAttributes'
import opsAPI from './ops'
import errorPassthroughAPI from './errorPassthrough'
import dataManagementAPI from './dataManagement'
import apiKeysAPI from './apiKeys'
import scheduledTestsAPI from './scheduledTests'
/**
* Unified admin API object for convenient access
@@ -41,7 +44,10 @@ export const adminAPI = {
antigravity: antigravityAPI,
userAttributes: userAttributesAPI,
ops: opsAPI,
errorPassthrough: errorPassthroughAPI
errorPassthrough: errorPassthroughAPI,
dataManagement: dataManagementAPI,
apiKeys: apiKeysAPI,
scheduledTests: scheduledTestsAPI
}
export {
@@ -61,7 +67,10 @@ export {
antigravityAPI,
userAttributesAPI,
opsAPI,
errorPassthroughAPI
errorPassthroughAPI,
dataManagementAPI,
apiKeysAPI,
scheduledTestsAPI
}
export default adminAPI
@@ -69,3 +78,4 @@ export default adminAPI
// Re-export types used by components
export type { BalanceHistoryItem } from './users'
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'

View File

@@ -259,6 +259,47 @@ export interface OpsErrorDistributionResponse {
items: OpsErrorDistributionItem[]
}
export interface OpsDashboardSnapshotV2Response {
generated_at: string
overview: OpsDashboardOverview
throughput_trend: OpsThroughputTrendResponse
error_trend: OpsErrorTrendResponse
}
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
@@ -376,7 +417,6 @@ export interface PlatformAvailability {
total_accounts: number
available_count: number
rate_limit_count: number
scope_rate_limit_count?: Record<string, number>
error_count: number
}
@@ -387,7 +427,6 @@ export interface GroupAvailability {
total_accounts: number
available_count: number
rate_limit_count: number
scope_rate_limit_count?: Record<string, number>
error_count: number
}
@@ -402,7 +441,6 @@ export interface AccountAvailability {
is_rate_limited: boolean
rate_limit_reset_at?: string
rate_limit_remaining_sec?: number
scope_rate_limits?: Record<string, number>
is_overloaded: boolean
overload_until?: string
overload_remaining_sec?: number
@@ -819,6 +857,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
@@ -902,6 +1011,24 @@ export async function getDashboardOverview(
return data
}
export async function getDashboardSnapshotV2(
params: {
time_range?: '5m' | '30m' | '1h' | '6h' | '24h'
start_time?: string
end_time?: string
platform?: string
group_id?: number | null
mode?: OpsQueryMode
},
options: OpsRequestOptions = {}
): Promise<OpsDashboardSnapshotV2Response> {
const { data } = await apiClient.get<OpsDashboardSnapshotV2Response>('/admin/ops/dashboard/snapshot-v2', {
params,
signal: options.signal
})
return data
}
export async function getThroughputTrend(
params: {
time_range?: '5m' | '30m' | '1h' | '6h' | '24h'
@@ -974,6 +1101,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 = {
@@ -1163,6 +1301,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')
@@ -1186,11 +1354,13 @@ async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise<
}
export const opsAPI = {
getDashboardSnapshotV2,
getDashboardOverview,
getThroughputTrend,
getLatencyHistogram,
getErrorTrend,
getErrorDistribution,
getOpenAITokenStats,
getConcurrencyStats,
getUserConcurrencyStats,
getAccountAvailabilityStats,
@@ -1229,10 +1399,16 @@ export const opsAPI = {
updateEmailNotificationConfig,
getAlertRuntimeSettings,
updateAlertRuntimeSettings,
getRuntimeLogConfig,
updateRuntimeLogConfig,
resetRuntimeLogConfig,
getAdvancedSettings,
updateAdvancedSettings,
getMetricThresholds,
updateMetricThresholds
updateMetricThresholds,
listSystemLogs,
cleanupSystemLogs,
getSystemLogSinkHealth
}
export default opsAPI

View File

@@ -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,

View File

@@ -0,0 +1,85 @@
/**
* Admin Scheduled Tests API endpoints
* Handles scheduled test plan management for account connectivity monitoring
*/
import { apiClient } from '../client'
import type {
ScheduledTestPlan,
ScheduledTestResult,
CreateScheduledTestPlanRequest,
UpdateScheduledTestPlanRequest
} from '@/types'
/**
* List all scheduled test plans for an account
* @param accountId - Account ID
* @returns List of scheduled test plans
*/
export async function listByAccount(accountId: number): Promise<ScheduledTestPlan[]> {
const { data } = await apiClient.get<ScheduledTestPlan[]>(
`/admin/accounts/${accountId}/scheduled-test-plans`
)
return data ?? []
}
/**
* Create a new scheduled test plan
* @param req - Plan creation request
* @returns Created plan
*/
export async function create(req: CreateScheduledTestPlanRequest): Promise<ScheduledTestPlan> {
const { data } = await apiClient.post<ScheduledTestPlan>(
'/admin/scheduled-test-plans',
req
)
return data
}
/**
* Update an existing scheduled test plan
* @param id - Plan ID
* @param req - Fields to update
* @returns Updated plan
*/
export async function update(id: number, req: UpdateScheduledTestPlanRequest): Promise<ScheduledTestPlan> {
const { data } = await apiClient.put<ScheduledTestPlan>(
`/admin/scheduled-test-plans/${id}`,
req
)
return data
}
/**
* Delete a scheduled test plan
* @param id - Plan ID
*/
export async function deletePlan(id: number): Promise<void> {
await apiClient.delete(`/admin/scheduled-test-plans/${id}`)
}
/**
* List test results for a plan
* @param planId - Plan ID
* @param limit - Optional max number of results to return
* @returns List of test results
*/
export async function listResults(planId: number, limit?: number): Promise<ScheduledTestResult[]> {
const { data } = await apiClient.get<ScheduledTestResult[]>(
`/admin/scheduled-test-plans/${planId}/results`,
{
params: limit ? { limit } : undefined
}
)
return data ?? []
}
export const scheduledTestsAPI = {
listByAccount,
create,
update,
delete: deletePlan,
listResults
}
export default scheduledTestsAPI

View File

@@ -4,6 +4,12 @@
*/
import { apiClient } from '../client'
import type { CustomMenuItem } from '@/types'
export interface DefaultSubscriptionSetting {
group_id: number
validity_days: number
}
/**
* System settings interface
@@ -12,6 +18,7 @@ export interface SystemSettings {
// Registration settings
registration_enabled: boolean
email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
@@ -20,6 +27,7 @@ export interface SystemSettings {
// Default settings
default_balance: number
default_concurrency: number
default_subscriptions: DefaultSubscriptionSetting[]
// OEM settings
site_name: string
site_logo: string
@@ -31,6 +39,8 @@ export interface SystemSettings {
hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
sora_client_enabled: boolean
custom_menu_items: CustomMenuItem[]
// SMTP settings
smtp_host: string
smtp_port: number
@@ -66,17 +76,25 @@ export interface SystemSettings {
ops_realtime_monitoring_enabled: boolean
ops_query_mode_default: 'auto' | 'raw' | 'preagg' | string
ops_metrics_interval_seconds: number
// Claude Code version check
min_claude_code_version: string
// 分组隔离
allow_ungrouped_key_scheduling: boolean
}
export interface UpdateSettingsRequest {
registration_enabled?: boolean
email_verify_enabled?: boolean
registration_email_suffix_whitelist?: string[]
promo_code_enabled?: boolean
password_reset_enabled?: boolean
invitation_code_enabled?: boolean
totp_enabled?: boolean // TOTP 双因素认证
default_balance?: number
default_concurrency?: number
default_subscriptions?: DefaultSubscriptionSetting[]
site_name?: string
site_logo?: string
site_subtitle?: string
@@ -87,6 +105,8 @@ export interface UpdateSettingsRequest {
hide_ccs_import_button?: boolean
purchase_subscription_enabled?: boolean
purchase_subscription_url?: string
sora_client_enabled?: boolean
custom_menu_items?: CustomMenuItem[]
smtp_host?: string
smtp_port?: number
smtp_username?: string
@@ -112,6 +132,8 @@ export interface UpdateSettingsRequest {
ops_realtime_monitoring_enabled?: boolean
ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string
ops_metrics_interval_seconds?: number
min_claude_code_version?: string
allow_ungrouped_key_scheduling?: boolean
}
/**
@@ -251,6 +273,177 @@ export async function updateStreamTimeoutSettings(
return data
}
// ==================== Rectifier Settings ====================
/**
* Rectifier settings interface
*/
export interface RectifierSettings {
enabled: boolean
thinking_signature_enabled: boolean
thinking_budget_enabled: boolean
}
/**
* Get rectifier settings
* @returns Rectifier settings
*/
export async function getRectifierSettings(): Promise<RectifierSettings> {
const { data } = await apiClient.get<RectifierSettings>('/admin/settings/rectifier')
return data
}
/**
* Update rectifier settings
* @param settings - Rectifier settings to update
* @returns Updated settings
*/
export async function updateRectifierSettings(
settings: RectifierSettings
): Promise<RectifierSettings> {
const { data } = await apiClient.put<RectifierSettings>(
'/admin/settings/rectifier',
settings
)
return data
}
// ==================== Sora S3 Settings ====================
export interface SoraS3Settings {
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key_configured: boolean
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface SoraS3Profile {
profile_id: string
name: string
is_active: boolean
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key_configured: boolean
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
updated_at: string
}
export interface ListSoraS3ProfilesResponse {
active_profile_id: string
items: SoraS3Profile[]
}
export interface UpdateSoraS3SettingsRequest {
profile_id?: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface CreateSoraS3ProfileRequest {
profile_id: string
name: string
set_active?: boolean
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface UpdateSoraS3ProfileRequest {
name: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes: number
}
export interface TestSoraS3ConnectionRequest {
profile_id?: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key?: string
prefix: string
force_path_style: boolean
cdn_url: string
default_storage_quota_bytes?: number
}
export async function getSoraS3Settings(): Promise<SoraS3Settings> {
const { data } = await apiClient.get<SoraS3Settings>('/admin/settings/sora-s3')
return data
}
export async function updateSoraS3Settings(settings: UpdateSoraS3SettingsRequest): Promise<SoraS3Settings> {
const { data } = await apiClient.put<SoraS3Settings>('/admin/settings/sora-s3', settings)
return data
}
export async function testSoraS3Connection(
settings: TestSoraS3ConnectionRequest
): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>('/admin/settings/sora-s3/test', settings)
return data
}
export async function listSoraS3Profiles(): Promise<ListSoraS3ProfilesResponse> {
const { data } = await apiClient.get<ListSoraS3ProfilesResponse>('/admin/settings/sora-s3/profiles')
return data
}
export async function createSoraS3Profile(request: CreateSoraS3ProfileRequest): Promise<SoraS3Profile> {
const { data } = await apiClient.post<SoraS3Profile>('/admin/settings/sora-s3/profiles', request)
return data
}
export async function updateSoraS3Profile(profileID: string, request: UpdateSoraS3ProfileRequest): Promise<SoraS3Profile> {
const { data } = await apiClient.put<SoraS3Profile>(`/admin/settings/sora-s3/profiles/${profileID}`, request)
return data
}
export async function deleteSoraS3Profile(profileID: string): Promise<void> {
await apiClient.delete(`/admin/settings/sora-s3/profiles/${profileID}`)
}
export async function setActiveSoraS3Profile(profileID: string): Promise<SoraS3Profile> {
const { data } = await apiClient.post<SoraS3Profile>(`/admin/settings/sora-s3/profiles/${profileID}/activate`)
return data
}
export const settingsAPI = {
getSettings,
updateSettings,
@@ -260,7 +453,17 @@ export const settingsAPI = {
regenerateAdminApiKey,
deleteAdminApiKey,
getStreamTimeoutSettings,
updateStreamTimeoutSettings
updateStreamTimeoutSettings,
getRectifierSettings,
updateRectifierSettings,
getSoraS3Settings,
updateSoraS3Settings,
testSoraS3Connection,
listSoraS3Profiles,
createSoraS3Profile,
updateSoraS3Profile,
deleteSoraS3Profile,
setActiveSoraS3Profile
}
export default settingsAPI

View File

@@ -4,7 +4,7 @@
*/
import { apiClient } from '../client'
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse, UsageRequestType } from '@/types'
// ==================== Types ====================
@@ -39,6 +39,7 @@ export interface UsageCleanupFilters {
account_id?: number
group_id?: number
model?: string | null
request_type?: UsageRequestType | null
stream?: boolean | null
billing_type?: number | null
}
@@ -66,6 +67,7 @@ export interface CreateUsageCleanupTaskRequest {
account_id?: number
group_id?: number
model?: string | null
request_type?: UsageRequestType | null
stream?: boolean | null
billing_type?: number | null
timezone?: string
@@ -73,6 +75,7 @@ export interface CreateUsageCleanupTaskRequest {
export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number
exact_total?: boolean
}
// ==================== API Functions ====================
@@ -104,6 +107,7 @@ export async function getStats(params: {
account_id?: number
group_id?: number
model?: string
request_type?: UsageRequestType
stream?: boolean
period?: string
start_date?: string

View File

@@ -4,7 +4,7 @@
*/
import { apiClient } from '../client'
import type { AdminUser, UpdateUserRequest, PaginatedResponse } from '@/types'
import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types'
/**
* List all users with pagination
@@ -22,6 +22,7 @@ export async function list(
role?: 'admin' | 'user'
search?: string
attributes?: Record<number, string> // attributeId -> value
include_subscriptions?: boolean
},
options?: {
signal?: AbortSignal
@@ -33,7 +34,8 @@ export async function list(
page_size: pageSize,
status: filters?.status,
role: filters?.role,
search: filters?.search
search: filters?.search,
include_subscriptions: filters?.include_subscriptions
}
// Add attribute filters as attr[id]=value
@@ -145,8 +147,8 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P
* @param id - User ID
* @returns List of user's API keys
*/
export async function getUserApiKeys(id: number): Promise<PaginatedResponse<any>> {
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/users/${id}/api-keys`)
export async function getUserApiKeys(id: number): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>(`/admin/users/${id}/api-keys`)
return data
}

View File

@@ -267,6 +267,7 @@ apiClient.interceptors.response.use(
return Promise.reject({
status,
code: apiData.code,
error: apiData.error,
message: apiData.message || apiData.detail || error.message
})
}

View File

@@ -10,18 +10,20 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons
* List all API keys for current user
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 10)
* @param filters - Optional filter parameters
* @param options - Optional request options
* @returns Paginated list of API keys
*/
export async function list(
page: number = 1,
pageSize: number = 10,
filters?: { search?: string; status?: string; group_id?: number | string },
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', {
params: { page, page_size: pageSize },
params: { page, page_size: pageSize, ...filters },
signal: options?.signal
})
return data
@@ -46,6 +48,7 @@ export async function getById(id: number): Promise<ApiKey> {
* @param ipBlacklist - Optional IP blacklist
* @param quota - Optional quota limit in USD (0 = unlimited)
* @param expiresInDays - Optional days until expiry (undefined = never expires)
* @param rateLimitData - Optional rate limit fields
* @returns Created API key
*/
export async function create(
@@ -55,7 +58,8 @@ export async function create(
ipWhitelist?: string[],
ipBlacklist?: string[],
quota?: number,
expiresInDays?: number
expiresInDays?: number,
rateLimitData?: { rate_limit_5h?: number; rate_limit_1d?: number; rate_limit_7d?: number }
): Promise<ApiKey> {
const payload: CreateApiKeyRequest = { name }
if (groupId !== undefined) {
@@ -76,6 +80,15 @@ export async function create(
if (expiresInDays !== undefined && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
if (rateLimitData?.rate_limit_5h && rateLimitData.rate_limit_5h > 0) {
payload.rate_limit_5h = rateLimitData.rate_limit_5h
}
if (rateLimitData?.rate_limit_1d && rateLimitData.rate_limit_1d > 0) {
payload.rate_limit_1d = rateLimitData.rate_limit_1d
}
if (rateLimitData?.rate_limit_7d && rateLimitData.rate_limit_7d > 0) {
payload.rate_limit_7d = rateLimitData.rate_limit_7d
}
const { data } = await apiClient.post<ApiKey>('/keys', payload)
return data

307
frontend/src/api/sora.ts Normal file
View File

@@ -0,0 +1,307 @@
/**
* Sora 客户端 API
* 封装所有 Sora 生成、作品库、配额等接口调用
*/
import { apiClient } from './client'
// ==================== 类型定义 ====================
export interface SoraGeneration {
id: number
user_id: number
model: string
prompt: string
media_type: string
status: string // pending | generating | completed | failed | cancelled
storage_type: string // upstream | s3 | local
media_url: string
media_urls: string[]
s3_object_keys: string[]
file_size_bytes: number
error_message: string
created_at: string
completed_at?: string
}
export interface GenerateRequest {
model: string
prompt: string
video_count?: number
media_type?: string
image_input?: string
api_key_id?: number
}
export interface GenerateResponse {
generation_id: number
status: string
}
export interface GenerationListResponse {
data: SoraGeneration[]
total: number
page: number
}
export interface QuotaInfo {
quota_bytes: number
used_bytes: number
available_bytes: number
quota_source: string // user | group | system | unlimited
source?: string // 兼容旧字段
}
export interface StorageStatus {
s3_enabled: boolean
s3_healthy: boolean
local_enabled: boolean
}
/** 单个扁平模型(旧接口,保留兼容) */
export interface SoraModel {
id: string
name: string
type: string // video | image
orientation?: string
duration?: number
}
/** 模型家族(新接口 — 后端从 soraModelConfigs 自动聚合) */
export interface SoraModelFamily {
id: string // 家族 ID如 "sora2"
name: string // 显示名,如 "Sora 2"
type: string // "video" | "image"
orientations: string[] // ["landscape", "portrait"] 或 ["landscape", "portrait", "square"]
durations?: number[] // [10, 15, 25](仅视频模型)
}
type LooseRecord = Record<string, unknown>
function asRecord(value: unknown): LooseRecord | null {
return value !== null && typeof value === 'object' ? value as LooseRecord : null
}
function asArray<T = unknown>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : []
}
function asPositiveInt(value: unknown): number | null {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return null
return Math.round(n)
}
function dedupeStrings(values: string[]): string[] {
return Array.from(new Set(values))
}
function extractOrientationFromModelID(modelID: string): string | null {
const m = modelID.match(/-(landscape|portrait|square)(?:-\d+s)?$/i)
return m ? m[1].toLowerCase() : null
}
function extractDurationFromModelID(modelID: string): number | null {
const m = modelID.match(/-(\d+)s$/i)
return m ? asPositiveInt(m[1]) : null
}
function normalizeLegacyFamilies(candidates: unknown[]): SoraModelFamily[] {
const familyMap = new Map<string, SoraModelFamily>()
for (const item of candidates) {
const model = asRecord(item)
if (!model || typeof model.id !== 'string' || model.id.trim() === '') continue
const rawID = model.id.trim()
const type = model.type === 'image' ? 'image' : 'video'
const name = typeof model.name === 'string' && model.name.trim() ? model.name.trim() : rawID
const baseID = rawID.replace(/-(landscape|portrait|square)(?:-\d+s)?$/i, '')
const orientation =
typeof model.orientation === 'string' && model.orientation
? model.orientation.toLowerCase()
: extractOrientationFromModelID(rawID)
const duration = asPositiveInt(model.duration) ?? extractDurationFromModelID(rawID)
const familyKey = baseID || rawID
const family = familyMap.get(familyKey) ?? {
id: familyKey,
name,
type,
orientations: [],
durations: []
}
if (orientation) {
family.orientations.push(orientation)
}
if (type === 'video' && duration) {
family.durations = family.durations || []
family.durations.push(duration)
}
familyMap.set(familyKey, family)
}
return Array.from(familyMap.values())
.map((family) => ({
...family,
orientations:
family.orientations.length > 0
? dedupeStrings(family.orientations)
: (family.type === 'image' ? ['square'] : ['landscape']),
durations:
family.type === 'video'
? Array.from(new Set((family.durations || []).filter((d): d is number => Number.isFinite(d)))).sort((a, b) => a - b)
: []
}))
.filter((family) => family.id !== '')
}
function normalizeModelFamilyRecord(item: unknown): SoraModelFamily | null {
const model = asRecord(item)
if (!model || typeof model.id !== 'string' || model.id.trim() === '') return null
// 仅把明确的“家族结构”识别为 family老结构单模型走 legacy 聚合逻辑。
if (!Array.isArray(model.orientations) && !Array.isArray(model.durations)) return null
const orientations = asArray<string>(model.orientations).filter((o): o is string => typeof o === 'string' && o.length > 0)
const durations = asArray<unknown>(model.durations)
.map(asPositiveInt)
.filter((d): d is number => d !== null)
return {
id: model.id.trim(),
name: typeof model.name === 'string' && model.name.trim() ? model.name.trim() : model.id.trim(),
type: model.type === 'image' ? 'image' : 'video',
orientations: dedupeStrings(orientations),
durations: Array.from(new Set(durations)).sort((a, b) => a - b)
}
}
function extractCandidateArray(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload
const record = asRecord(payload)
if (!record) return []
const keys: Array<keyof LooseRecord> = ['data', 'items', 'models', 'families']
for (const key of keys) {
if (Array.isArray(record[key])) {
return record[key] as unknown[]
}
}
return []
}
export function normalizeModelFamiliesResponse(payload: unknown): SoraModelFamily[] {
const candidates = extractCandidateArray(payload)
if (candidates.length === 0) return []
const normalized = candidates
.map(normalizeModelFamilyRecord)
.filter((item): item is SoraModelFamily => item !== null)
if (normalized.length > 0) return normalized
return normalizeLegacyFamilies(candidates)
}
export function normalizeGenerationListResponse(payload: unknown): GenerationListResponse {
const record = asRecord(payload)
if (!record) {
return { data: [], total: 0, page: 1 }
}
const data = Array.isArray(record.data)
? (record.data as SoraGeneration[])
: Array.isArray(record.items)
? (record.items as SoraGeneration[])
: []
const total = Number(record.total)
const page = Number(record.page)
return {
data,
total: Number.isFinite(total) ? total : data.length,
page: Number.isFinite(page) && page > 0 ? page : 1
}
}
// ==================== API 方法 ====================
/** 异步生成 — 创建 pending 记录后立即返回 */
export async function generate(req: GenerateRequest): Promise<GenerateResponse> {
const { data } = await apiClient.post<GenerateResponse>('/sora/generate', req)
return data
}
/** 查询生成记录列表 */
export async function listGenerations(params?: {
page?: number
page_size?: number
status?: string
storage_type?: string
media_type?: string
}): Promise<GenerationListResponse> {
const { data } = await apiClient.get<unknown>('/sora/generations', { params })
return normalizeGenerationListResponse(data)
}
/** 查询生成记录详情 */
export async function getGeneration(id: number): Promise<SoraGeneration> {
const { data } = await apiClient.get<SoraGeneration>(`/sora/generations/${id}`)
return data
}
/** 删除生成记录 */
export async function deleteGeneration(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/sora/generations/${id}`)
return data
}
/** 取消生成任务 */
export async function cancelGeneration(id: number): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>(`/sora/generations/${id}/cancel`)
return data
}
/** 手动保存到 S3 */
export async function saveToStorage(
id: number
): Promise<{ message: string; object_key: string; object_keys?: string[] }> {
const { data } = await apiClient.post<{ message: string; object_key: string; object_keys?: string[] }>(
`/sora/generations/${id}/save`
)
return data
}
/** 查询配额信息 */
export async function getQuota(): Promise<QuotaInfo> {
const { data } = await apiClient.get<QuotaInfo>('/sora/quota')
return data
}
/** 获取可用模型家族列表 */
export async function getModels(): Promise<SoraModelFamily[]> {
const { data } = await apiClient.get<unknown>('/sora/models')
return normalizeModelFamiliesResponse(data)
}
/** 获取存储状态 */
export async function getStorageStatus(): Promise<StorageStatus> {
const { data } = await apiClient.get<StorageStatus>('/sora/storage-status')
return data
}
const soraAPI = {
generate,
listGenerations,
getGeneration,
deleteGeneration,
cancelGeneration,
saveToStorage,
getQuota,
getModels,
getStorageStatus
}
export default soraAPI

View 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()
})
})

View 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)
})
})

View 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()
})
})

View File

@@ -52,6 +52,43 @@
<span class="font-mono">{{ account.max_sessions }}</span>
</span>
</div>
<!-- RPM 限制 Anthropic OAuth/SetupToken 且启用时显示 -->
<div v-if="showRpmLimit" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
rpmClass
]"
:title="rpmTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="font-mono">{{ currentRPM }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.base_rpm }}</span>
<span class="text-[9px] opacity-60">{{ rpmStrategyTag }}</span>
</span>
</div>
<!-- API Key 账号配额限制 -->
<div v-if="showQuotaLimit" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
quotaClass
]"
:title="quotaTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
<span class="font-mono">${{ formatCost(currentQuotaUsed) }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">${{ formatCost(account.quota_limit) }}</span>
</span>
</div>
</div>
</template>
@@ -125,19 +162,15 @@ const windowCostClass = computed(() => {
const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10
// >= 阈值+预留: 完全不可调度 (红色)
if (current >= limit + reserve) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
// >= 阈值: 仅粘性会话 (橙色)
if (current >= limit) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
// >= 80% 阈值: 警告 (黄色)
if (current >= limit * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
// 正常 (绿色)
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
@@ -165,15 +198,12 @@ const sessionLimitClass = computed(() => {
const current = activeSessions.value
const max = props.account.max_sessions || 0
// >= 最大: 完全占满 (红色)
if (current >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
// >= 80%: 警告 (黄色)
if (current >= max * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
// 正常 (绿色)
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
@@ -191,6 +221,131 @@ const sessionLimitTooltip = computed(() => {
return t('admin.accounts.capacity.sessions.normal', { idle })
})
// 是否显示 RPM 限制
const showRpmLimit = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.base_rpm !== undefined &&
props.account.base_rpm !== null &&
props.account.base_rpm > 0
)
})
// 当前 RPM 计数
const currentRPM = computed(() => props.account.current_rpm ?? 0)
// RPM 策略
const rpmStrategy = computed(() => props.account.rpm_strategy || 'tiered')
// RPM 策略标签
const rpmStrategyTag = computed(() => {
return rpmStrategy.value === 'sticky_exempt' ? '[S]' : '[T]'
})
// RPM buffer 计算与后端一致base <= 0 时 buffer 为 0
const rpmBuffer = computed(() => {
const base = props.account.base_rpm || 0
return props.account.rpm_sticky_buffer ?? (base > 0 ? Math.max(1, Math.floor(base / 5)) : 0)
})
// RPM 状态样式
const rpmClass = computed(() => {
if (!showRpmLimit.value) return ''
const current = currentRPM.value
const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current >= base) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
} else {
if (current >= base) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
}
if (current >= base * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// RPM 提示文字(增强版:显示策略、区域、缓冲区)
const rpmTooltip = computed(() => {
if (!showRpmLimit.value) return ''
const current = currentRPM.value
const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) {
return t('admin.accounts.capacity.rpm.tieredBlocked', { buffer })
}
if (current >= base) {
return t('admin.accounts.capacity.rpm.tieredStickyOnly', { buffer })
}
if (current >= base * 0.8) {
return t('admin.accounts.capacity.rpm.tieredWarning')
}
return t('admin.accounts.capacity.rpm.tieredNormal')
} else {
if (current >= base) {
return t('admin.accounts.capacity.rpm.stickyExemptOver')
}
if (current >= base * 0.8) {
return t('admin.accounts.capacity.rpm.stickyExemptWarning')
}
return t('admin.accounts.capacity.rpm.stickyExemptNormal')
}
})
// 是否显示配额限制(仅 apikey 类型且设置了 quota_limit
const showQuotaLimit = computed(() => {
return (
props.account.type === 'apikey' &&
props.account.quota_limit !== undefined &&
props.account.quota_limit !== null &&
props.account.quota_limit > 0
)
})
// 当前已用配额
const currentQuotaUsed = computed(() => props.account.quota_used ?? 0)
// 配额状态样式
const quotaClass = computed(() => {
if (!showQuotaLimit.value) return ''
const used = currentQuotaUsed.value
const limit = props.account.quota_limit || 0
if (used >= limit) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (used >= limit * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// 配额提示文字
const quotaTooltip = computed(() => {
if (!showQuotaLimit.value) return ''
const used = currentQuotaUsed.value
const limit = props.account.quota_limit || 0
if (used >= limit) {
return t('admin.accounts.capacity.quota.exceeded')
}
return t('admin.accounts.capacity.quota.normal')
})
// 格式化费用显示
const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0'

View File

@@ -41,7 +41,7 @@
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.allGroups', { count: groups.length }) }}
{{ t('admin.accounts.groupCountTotal', { count: groups.length }) }}
</span>
<button
@click="showPopover = false"

View File

@@ -76,34 +76,24 @@
</div>
</div>
<!-- Scope Rate Limit Indicators (Antigravity) -->
<template v-if="activeScopeRateLimits.length > 0">
<div v-for="item in activeScopeRateLimits" :key="item.scope" class="group relative">
<span
class="inline-flex items-center gap-1 rounded bg-orange-100 px-1.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ formatScopeName(item.scope) }}
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.scopeRateLimitedUntil', { scope: formatScopeName(item.scope), time: formatTime(item.reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" ></div>
</div>
</div>
</template>
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
<template v-if="activeModelRateLimits.length > 0">
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative">
<div
v-if="activeModelRateLimits.length > 0"
:class="[
activeModelRateLimits.length <= 4
? 'flex flex-col gap-1'
: activeModelRateLimits.length <= 8
? 'columns-2 gap-x-2'
: 'columns-3 gap-x-2'
]"
>
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative mb-1 break-inside-avoid">
<span
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ formatScopeName(item.model) }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span>
<!-- Tooltip -->
<div
@@ -115,7 +105,7 @@
></div>
</div>
</div>
</template>
</div>
<!-- Overload Indicator (529) -->
<div v-if="isOverloaded" class="group relative">
@@ -160,15 +150,6 @@ const isRateLimited = computed(() => {
return new Date(props.account.rate_limit_reset_at) > new Date()
})
// Computed: active scope rate limits (Antigravity)
const activeScopeRateLimits = computed(() => {
const scopeLimits = props.account.scope_rate_limits
if (!scopeLimits) return []
const now = new Date()
return Object.entries(scopeLimits)
.filter(([, info]) => new Date(info.reset_at) > now)
.map(([scope, info]) => ({ scope, reset_at: info.reset_at }))
})
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
const activeModelRateLimits = computed(() => {
@@ -183,17 +164,52 @@ const activeModelRateLimits = computed(() => {
})
const formatScopeName = (scope: string): string => {
const names: Record<string, string> = {
const aliases: Record<string, string> = {
// Claude 系列
'claude-opus-4-6': 'COpus46',
'claude-opus-4-6-thinking': 'COpus46T',
'claude-sonnet-4-6': 'CSon46',
'claude-sonnet-4-5': 'CSon45',
'claude-sonnet-4-5-thinking': 'CSon45T',
// Gemini 2.5 系列
'gemini-2.5-flash': 'G25F',
'gemini-2.5-flash-lite': 'G25FL',
'gemini-2.5-flash-thinking': 'G25FT',
'gemini-2.5-pro': 'G25P',
// Gemini 3 系列
'gemini-3-flash': 'G3F',
'gemini-3.1-pro-high': 'G3PH',
'gemini-3.1-pro-low': 'G3PL',
'gemini-3-pro-image': 'G3PI',
'gemini-3.1-flash-image': 'GImage',
// 其他
'gpt-oss-120b-medium': 'GPT120',
'tab_flash_lite_preview': 'TabFL',
// 旧版 scope 别名(兼容)
claude: 'Claude',
claude_sonnet: 'Claude Sonnet',
claude_opus: 'Claude Opus',
claude_haiku: 'Claude Haiku',
claude_sonnet: 'CSon',
claude_opus: 'COpus',
claude_haiku: 'CHaiku',
gemini_text: 'Gemini',
gemini_image: 'Image',
gemini_flash: 'Gemini Flash',
gemini_pro: 'Gemini Pro'
gemini_image: 'GImg',
gemini_flash: 'GFlash',
gemini_pro: 'GPro',
}
return names[scope] || scope
return aliases[scope] || scope
}
const formatModelResetTime = (resetAt: string): string => {
const date = new Date(resetAt)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
if (diffMs <= 0) return ''
const totalSecs = Math.floor(diffMs / 1000)
const h = Math.floor(totalSecs / 3600)
const m = Math.floor((totalSecs % 3600) / 60)
const s = totalSecs % 60
if (h > 0) return `${h}h${m}m`
if (m > 0) return `${m}m${s}s`
return `${s}s`
}
// Computed: is overloaded (529)

View File

@@ -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

View File

@@ -1,26 +1,26 @@
<template>
<div>
<!-- Loading state -->
<div v-if="loading" class="space-y-0.5">
<div v-if="props.loading && !props.stats" class="space-y-0.5">
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
<div v-else-if="props.error && !props.stats" class="text-xs text-red-500">
{{ props.error }}
</div>
<!-- Stats data -->
<div v-else-if="stats" class="space-y-0.5 text-xs">
<div v-else-if="props.stats" class="space-y-0.5 text-xs">
<!-- Requests -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.requests') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatNumber(stats.requests)
formatNumber(props.stats.requests)
}}</span>
</div>
<!-- Tokens -->
@@ -29,21 +29,21 @@
>{{ t('admin.accounts.stats.tokens') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatTokens(stats.tokens)
formatTokens(props.stats.tokens)
}}</span>
</div>
<!-- Cost (Account) -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}:</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost)
formatCurrency(props.stats.cost)
}}</span>
</div>
<!-- Cost (User/API Key) -->
<div v-if="stats.user_cost != null" class="flex items-center gap-1">
<div v-if="props.stats.user_cost != null" class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatCurrency(stats.user_cost)
formatCurrency(props.stats.user_cost)
}}</span>
</div>
</div>
@@ -54,22 +54,25 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, WindowStats } from '@/types'
import type { WindowStats } from '@/types'
import { formatNumber, formatCurrency } from '@/utils/format'
const props = defineProps<{
account: Account
}>()
const props = withDefaults(
defineProps<{
stats?: WindowStats | null
loading?: boolean
error?: string | null
}>(),
{
stats: null,
loading: false,
error: null
}
)
const { t } = useI18n()
const loading = ref(false)
const error = ref<string | null>(null)
const stats = ref<WindowStats | null>(null)
// Format large token numbers (e.g., 1234567 -> 1.23M)
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
@@ -79,22 +82,4 @@ const formatTokens = (tokens: number): string => {
}
return tokens.toString()
}
const loadStats = async () => {
loading.value = true
error.value = null
try {
stats.value = await adminAPI.accounts.getTodayStats(props.account.id)
} catch (e: any) {
error.value = 'Failed'
console.error('Failed to load today stats:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadStats()
})
</script>

View File

@@ -90,6 +90,36 @@
color="emerald"
/>
</div>
<div v-else-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div v-else-if="hasOpenAIUsageFallback" class="space-y-1">
<UsageProgressBar
v-if="usageInfo?.five_hour"
label="5h"
:utilization="usageInfo.five_hour.utilization"
:resets-at="usageInfo.five_hour.resets_at"
:window-stats="usageInfo.five_hour.window_stats"
color="indigo"
/>
<UsageProgressBar
v-if="usageInfo?.seven_day"
label="7d"
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
:window-stats="usageInfo.seven_day.window_stats"
color="emerald"
/>
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
@@ -172,12 +202,12 @@
color="purple"
/>
<!-- Claude 4.5 -->
<!-- Claude -->
<UsageProgressBar
v-if="antigravityClaude45UsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.claude45')"
:utilization="antigravityClaude45UsageFromAPI.utilization"
:resets-at="antigravityClaude45UsageFromAPI.resetTime"
v-if="antigravityClaudeUsageFromAPI !== null"
:label="t('admin.accounts.usageWindow.claude')"
:utilization="antigravityClaudeUsageFromAPI.utilization"
:resets-at="antigravityClaudeUsageFromAPI.resetTime"
color="amber"
/>
</div>
@@ -282,6 +312,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'
@@ -312,6 +343,9 @@ const shouldFetchUsage = computed(() => {
if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth'
}
if (props.account.platform === 'openai') {
return props.account.type === 'oauth'
}
return false
})
@@ -326,153 +360,23 @@ 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 hasOpenAIUsageFallback = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
return !!usageInfo.value?.five_hour || !!usageInfo.value?.seven_day
})
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 {
@@ -531,12 +435,17 @@ const antigravity3ProUsageFromAPI = computed(() =>
// Gemini 3 Flash from API
const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
// Gemini 3 Image from API
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-pro-image']))
// Gemini Image from API
const antigravity3ImageUsageFromAPI = computed(() =>
getAntigravityUsageFromAPI(['gemini-3.1-flash-image', 'gemini-3-pro-image'])
)
// Claude 4.5 from API
const antigravityClaude45UsageFromAPI = computed(() =>
getAntigravityUsageFromAPI(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
// Claude from API (all Claude model variants)
const antigravityClaudeUsageFromAPI = computed(() =>
getAntigravityUsageFromAPI([
'claude-sonnet-4-5', 'claude-opus-4-5-thinking',
'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-opus-4-6-thinking',
])
)
// Antigravity 账户类型(从 load_code_assist 响应中提取)

View File

@@ -21,6 +21,16 @@
</p>
</div>
<!-- Mixed platform warning -->
<div v-if="isMixedPlatform" class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<p class="text-sm text-amber-700 dark:text-amber-400">
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: selectedPlatforms.join(', ') }) }}
</p>
</div>
<!-- Base URL (API Key only) -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -157,7 +167,7 @@
<!-- Model Checkbox List -->
<div class="mb-3 grid grid-cols-2 gap-2">
<label
v-for="model in allModels"
v-for="model in filteredModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="
@@ -278,7 +288,7 @@
<!-- Quick Add Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presetMappings"
v-for="preset in filteredPresets"
:key="preset.label"
type="button"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
@@ -459,7 +469,7 @@
</div>
<!-- Concurrency & Priority -->
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600 lg:grid-cols-3">
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600 lg:grid-cols-4">
<div>
<div class="mb-3 flex items-center justify-between">
<label
@@ -486,8 +496,39 @@
class="input"
:class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-concurrency-label"
@input="concurrency = Math.max(1, concurrency || 1)"
/>
</div>
<div>
<div class="mb-3 flex items-center justify-between">
<label
id="bulk-edit-load-factor-label"
class="input-label mb-0"
for="bulk-edit-load-factor-enabled"
>
{{ t('admin.accounts.loadFactor') }}
</label>
<input
v-model="enableLoadFactor"
id="bulk-edit-load-factor-enabled"
type="checkbox"
aria-controls="bulk-edit-load-factor"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<input
v-model.number="loadFactor"
id="bulk-edit-load-factor"
type="number"
min="1"
:disabled="!enableLoadFactor"
class="input"
:class="!enableLoadFactor && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-load-factor-label"
@input="loadFactor = (loadFactor &amp;&amp; loadFactor >= 1) ? loadFactor : null"
/>
<p class="input-hint">{{ t('admin.accounts.loadFactorHint') }}</p>
</div>
<div>
<div class="mb-3 flex items-center justify-between">
<label
@@ -575,6 +616,132 @@
</div>
</div>
<!-- RPM Limit (仅全部为 Anthropic OAuth/SetupToken 时显示) -->
<div v-if="allAnthropicOAuthOrSetupToken" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label
id="bulk-edit-rpm-limit-label"
class="input-label mb-0"
for="bulk-edit-rpm-limit-enabled"
>
{{ t('admin.accounts.quotaControl.rpmLimit.label') }}
</label>
<input
v-model="enableRpmLimit"
id="bulk-edit-rpm-limit-enabled"
type="checkbox"
aria-controls="bulk-edit-rpm-limit-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div
id="bulk-edit-rpm-limit-body"
:class="!enableRpmLimit && 'pointer-events-none opacity-50'"
role="group"
aria-labelledby="bulk-edit-rpm-limit-label"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}</span>
<button
type="button"
@click="rpmLimitEnabled = !rpmLimitEnabled"
: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',
rpmLimitEnabled ? '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',
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="rpmLimitEnabled" class="space-y-3">
<div>
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
<input
v-model.number="bulkBaseRpm"
type="number"
min="1"
max="1000"
step="1"
class="input"
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
</div>
<div>
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
<div class="flex gap-2">
<button
type="button"
@click="bulkRpmStrategy = 'tiered'"
:class="[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
bulkRpmStrategy === 'tiered'
? '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'
]"
>
{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}
</button>
<button
type="button"
@click="bulkRpmStrategy = 'sticky_exempt'"
:class="[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
bulkRpmStrategy === 'sticky_exempt'
? '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'
]"
>
{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}
</button>
</div>
</div>
<div v-if="bulkRpmStrategy === 'tiered'">
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
<input
v-model.number="bulkRpmStickyBuffer"
type="number"
min="1"
step="1"
class="input"
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
</div>
</div>
</div>
<!-- 用户消息限速模式独立于 RPM 开关始终可见 -->
<div class="mt-4">
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueue') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ t('admin.accounts.quotaControl.rpmLimit.userMsgQueueHint') }}
</p>
<div class="flex space-x-2">
<button type="button" v-for="opt in umqModeOptions" :key="opt.value"
@click="userMsgQueueMode = userMsgQueueMode === opt.value ? null : opt.value"
:class="[
'px-3 py-1.5 text-sm rounded-md border transition-colors',
userMsgQueueMode === opt.value
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white dark:bg-dark-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-dark-500 hover:bg-gray-50 dark:hover:bg-dark-600'
]">
{{ opt.label }}
</button>
</div>
</div>
</div>
<!-- Groups -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -641,6 +808,17 @@
</div>
</template>
</BaseDialog>
<ConfirmDialog
:show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningMessage"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleMixedChannelConfirm"
@cancel="handleMixedChannelCancel"
/>
</template>
<script setup lang="ts">
@@ -648,17 +826,21 @@ import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy, AdminGroup } from '@/types'
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
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 { buildModelMappingObject as buildModelMappingPayload } from '@/composables/useModelWhitelist'
interface Props {
show: boolean
accountIds: number[]
proxies: Proxy[]
selectedPlatforms: AccountPlatform[]
selectedTypes: AccountType[]
proxies: ProxyConfig[]
groups: AdminGroup[]
}
@@ -671,6 +853,40 @@ const emit = defineEmits<{
const { t } = useI18n()
const appStore = useAppStore()
// Platform awareness
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
// 是否全部为 Anthropic OAuth/SetupTokenRPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => {
return (
props.selectedPlatforms.length === 1 &&
props.selectedPlatforms[0] === 'anthropic' &&
props.selectedTypes.every(t => t === 'oauth' || t === 'setup-token')
)
})
const platformModelPrefix: Record<string, string[]> = {
anthropic: ['claude-'],
antigravity: ['claude-', 'gemini-', 'gpt-oss-', 'tab_'],
openai: ['gpt-'],
gemini: ['gemini-'],
sora: []
}
const filteredModels = computed(() => {
if (props.selectedPlatforms.length === 0) return allModels
const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
if (prefixes.length === 0) return allModels
return allModels.filter(m => prefixes.some(prefix => m.value.startsWith(prefix)))
})
const filteredPresets = computed(() => {
if (props.selectedPlatforms.length === 0) return presetMappings
const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
if (prefixes.length === 0) return presetMappings
return presetMappings.filter(m => prefixes.some(prefix => m.from.startsWith(prefix)))
})
// Model mapping type
interface ModelMapping {
from: string
@@ -684,13 +900,18 @@ const enableCustomErrorCodes = ref(false)
const enableInterceptWarmup = ref(false)
const enableProxy = ref(false)
const enableConcurrency = ref(false)
const enableLoadFactor = ref(false)
const enablePriority = ref(false)
const enableRateMultiplier = ref(false)
const enableStatus = ref(false)
const enableGroups = ref(false)
const enableRpmLimit = ref(false)
// State - field values
const submitting = ref(false)
const showMixedChannelWarning = ref(false)
const mixedChannelWarningMessage = ref('')
const pendingUpdatesForConfirm = ref<Record<string, unknown> | null>(null)
const baseUrl = ref('')
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
@@ -700,14 +921,26 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const proxyId = ref<number | null>(null)
const concurrency = ref(1)
const loadFactor = ref<number | null>(null)
const priority = ref(1)
const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active')
const groupIds = ref<number[]>([])
const rpmLimitEnabled = ref(false)
const bulkBaseRpm = ref<number | null>(null)
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
const bulkRpmStickyBuffer = ref<number | null>(null)
const userMsgQueueMode = ref<string | null>(null)
const umqModeOptions = computed(() => [
{ value: '', label: t('admin.accounts.quotaControl.rpmLimit.umqModeOff') },
{ value: 'throttle', label: t('admin.accounts.quotaControl.rpmLimit.umqModeThrottle') },
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
])
// All models list (combined Anthropic + OpenAI)
// All models list (combined Anthropic + OpenAI + Gemini)
const allModels = [
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
@@ -716,16 +949,26 @@ 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', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ 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' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-3.1-flash-image', label: 'Gemini 3.1 Flash Image' },
{ value: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image (Legacy)' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
]
// Preset mappings (combined Anthropic + OpenAI)
// Preset mappings (combined Anthropic + OpenAI + Gemini)
const presetMappings = [
{
label: 'Sonnet 4',
@@ -750,16 +993,91 @@ const presetMappings = [
{
label: 'Opus 4.6',
from: 'claude-opus-4-6',
to: 'claude-opus-4-6',
to: 'claude-opus-4-6-thinking',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Opus 4.6-thinking',
from: 'claude-opus-4-6-thinking',
to: 'claude-opus-4-6-thinking',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Sonnet 4.6',
from: 'claude-sonnet-4-6',
to: 'claude-sonnet-4-6',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Sonnet4→4.6',
from: 'claude-sonnet-4-20250514',
to: 'claude-sonnet-4-6',
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
},
{
label: 'Sonnet4.5→4.6',
from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-6',
color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400'
},
{
label: 'Sonnet3.5→4.6',
from: 'claude-3-5-sonnet-20241022',
to: 'claude-sonnet-4-6',
color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400'
},
{
label: 'Opus4.5→4.6',
from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-6-thinking',
color:
'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400'
},
{
label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101',
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: 'Gemini 3.1 Image',
from: 'gemini-3.1-flash-image',
to: 'gemini-3.1-flash-image',
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
},
{
label: 'G3 Image→3.1',
from: 'gemini-3-pro-image',
to: 'gemini-3.1-flash-image',
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
},
{
label: 'GPT-5.3 Codex',
from: 'gpt-5.3-codex',
to: 'gpt-5.3-codex',
color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'GPT-5.3 Spark',
from: 'gpt-5.3-codex-spark',
to: 'gpt-5.3-codex-spark',
color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'GPT-5.4',
from: 'gpt-5.4',
to: 'gpt-5.4',
color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400'
},
{
label: '5.2→5.3',
from: 'gpt-5.2-codex',
to: 'gpt-5.3-codex',
color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400'
},
{
label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11',
@@ -777,6 +1095,36 @@ const presetMappings = [
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex',
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
},
{
label: '3-Pro-Preview→3.1-Pro-High',
from: 'gemini-3-pro-preview',
to: 'gemini-3.1-pro-high',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
},
{
label: '3-Pro-High→3.1-Pro-High',
from: 'gemini-3-pro-high',
to: 'gemini-3.1-pro-high',
color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400'
},
{
label: '3-Pro-Low→3.1-Pro-Low',
from: 'gemini-3-pro-low',
to: 'gemini-3.1-pro-low',
color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400'
},
{
label: '3-Flash透传',
from: 'gemini-3-flash',
to: 'gemini-3-flash',
color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400'
},
{
label: '2.5-Flash-Lite透传',
from: 'gemini-2.5-flash-lite',
to: 'gemini-2.5-flash-lite',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
}
]
@@ -866,23 +1214,11 @@ const removeErrorCode = (code: number) => {
}
const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {}
if (modelRestrictionMode.value === 'whitelist') {
for (const model of allowedModels.value) {
mapping[model] = model
}
} else {
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
return buildModelMappingPayload(
modelRestrictionMode.value,
allowedModels.value,
modelMappings.value
)
}
const buildUpdatePayload = (): Record<string, unknown> | null => {
@@ -899,6 +1235,12 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates.concurrency = concurrency.value
}
if (enableLoadFactor.value) {
// 空值/NaN/0 时发送 0后端约定 <= 0 表示清除)
const lf = loadFactor.value
updates.load_factor = (lf != null && !Number.isNaN(lf) && lf > 0) ? lf : 0
}
if (enablePriority.value) {
updates.priority = priority.value
}
@@ -960,13 +1302,77 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates.credentials = credentials
}
// RPM limit settings (写入 extra 字段)
if (enableRpmLimit.value) {
const extra: Record<string, unknown> = {}
if (rpmLimitEnabled.value && bulkBaseRpm.value != null && bulkBaseRpm.value > 0) {
extra.base_rpm = bulkBaseRpm.value
extra.rpm_strategy = bulkRpmStrategy.value
if (bulkRpmStickyBuffer.value != null && bulkRpmStickyBuffer.value > 0) {
extra.rpm_sticky_buffer = bulkRpmStickyBuffer.value
}
} else {
// 关闭 RPM 限制 - 设置 base_rpm 为 0并用空值覆盖关联字段
// 后端使用 JSONB || merge 语义,不会删除已有 key
// 所以必须显式发送空值来重置(后端读取时会 fallback 到默认值)
extra.base_rpm = 0
extra.rpm_strategy = ''
extra.rpm_sticky_buffer = 0
}
updates.extra = extra
}
// UMQ mode独立于 RPM 保存)
if (userMsgQueueMode.value !== null) {
if (!updates.extra) updates.extra = {}
const umqExtra = updates.extra as Record<string, unknown>
umqExtra.user_msg_queue_mode = userMsgQueueMode.value // '' = 清除账号级覆盖
umqExtra.user_msg_queue_enabled = false // 清理旧字段JSONB merge
}
return Object.keys(updates).length > 0 ? updates : null
}
const mixedChannelConfirmed = ref(false)
// 是否需要预检查:改了分组 + 全是单一的 antigravity 或 anthropic 平台
// 多平台混合的情况由 submitBulkUpdate 的 409 catch 兜底
const canPreCheck = () =>
enableGroups.value &&
groupIds.value.length > 0 &&
props.selectedPlatforms.length === 1 &&
(props.selectedPlatforms[0] === 'antigravity' || props.selectedPlatforms[0] === 'anthropic')
const handleClose = () => {
showMixedChannelWarning.value = false
mixedChannelWarningMessage.value = ''
pendingUpdatesForConfirm.value = null
mixedChannelConfirmed.value = false
emit('close')
}
// 预检查:提交前调接口检测,有风险就弹窗阻止,返回 false 表示需要用户确认
const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise<boolean> => {
if (!canPreCheck()) return true
if (mixedChannelConfirmed.value) return true
try {
const result = await adminAPI.accounts.checkMixedChannelRisk({
platform: props.selectedPlatforms[0],
group_ids: groupIds.value
})
if (!result.has_risk) return true
pendingUpdatesForConfirm.value = built
mixedChannelWarningMessage.value = result.message || t('admin.accounts.bulkEdit.failed')
showMixedChannelWarning.value = true
return false
} catch (error: any) {
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
return false
}
}
const handleSubmit = async () => {
if (props.accountIds.length === 0) {
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
@@ -980,22 +1386,37 @@ const handleSubmit = async () => {
enableInterceptWarmup.value ||
enableProxy.value ||
enableConcurrency.value ||
enableLoadFactor.value ||
enablePriority.value ||
enableRateMultiplier.value ||
enableStatus.value ||
enableGroups.value
enableGroups.value ||
enableRpmLimit.value ||
userMsgQueueMode.value !== null
if (!hasAnyFieldEnabled) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
return
}
const updates = buildUpdatePayload()
if (!updates) {
const built = buildUpdatePayload()
if (!built) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
return
}
const canContinue = await preCheckMixedChannelRisk(built)
if (!canContinue) return
await submitBulkUpdate(built)
}
const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
// 无论是预检查确认还是 409 兜底确认,只要 mixedChannelConfirmed 为 true 就带上 flag
const updates = mixedChannelConfirmed.value
? { ...baseUpdates, confirm_mixed_channel_risk: true }
: baseUpdates
submitting.value = true
try {
@@ -1012,17 +1433,38 @@ const handleSubmit = async () => {
}
if (success > 0) {
pendingUpdatesForConfirm.value = null
emit('updated')
handleClose()
}
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed'))
console.error('Error bulk updating accounts:', error)
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
if (error.status === 409 && error.error === 'mixed_channel_warning') {
pendingUpdatesForConfirm.value = baseUpdates
mixedChannelWarningMessage.value = error.message
showMixedChannelWarning.value = true
} else {
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
console.error('Error bulk updating accounts:', error)
}
} finally {
submitting.value = false
}
}
const handleMixedChannelConfirm = async () => {
showMixedChannelWarning.value = false
mixedChannelConfirmed.value = true
if (pendingUpdatesForConfirm.value) {
await submitBulkUpdate(pendingUpdatesForConfirm.value)
}
}
const handleMixedChannelCancel = () => {
showMixedChannelWarning.value = false
pendingUpdatesForConfirm.value = null
}
// Reset form when modal closes
watch(
() => props.show,
@@ -1035,10 +1477,12 @@ watch(
enableInterceptWarmup.value = false
enableProxy.value = false
enableConcurrency.value = false
enableLoadFactor.value = false
enablePriority.value = false
enableRateMultiplier.value = false
enableStatus.value = false
enableGroups.value = false
enableRpmLimit.value = false
// Reset all values
baseUrl.value = ''
@@ -1050,10 +1494,22 @@ watch(
interceptWarmupRequests.value = false
proxyId.value = null
concurrency.value = 1
loadFactor.value = null
priority.value = 1
rateMultiplier.value = 1
status.value = 'active'
groupIds.value = []
rpmLimitEnabled.value = false
bulkBaseRpm.value = null
bulkRpmStrategy.value = 'tiered'
bulkRpmStickyBuffer.value = null
userMsgQueueMode.value = null
// Reset mixed channel warning state
showMixedChannelWarning.value = false
mixedChannelWarningMessage.value = ''
pendingUpdatesForConfirm.value = null
mixedChannelConfirmed.value = false
}
}
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -10,11 +10,11 @@
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
<!-- Auth Method Selection -->
<div v-if="showCookieOption" class="mb-4">
<div v-if="showMethodSelection" class="mb-4">
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
{{ methodLabel }}
</label>
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
@@ -26,7 +26,7 @@
t('admin.accounts.oauth.manualAuth')
}}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<label v-if="showCookieOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
@@ -37,6 +37,334 @@
t('admin.accounts.oauth.cookieAutoAuth')
}}</span>
</label>
<label v-if="showRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="refresh_token"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
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>
<label v-if="showAccessTokenOption" class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="access_token"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
}}</span>
</label>
</div>
</div>
<!-- Refresh Token Input (OpenAI / Antigravity) -->
<div v-if="inputMethod === 'refresh_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('refreshTokenDesc')) }}
</p>
<!-- Refresh Token Input -->
<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" />
Refresh Token
<span
v-if="parsedRefreshTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedRefreshTokenCount }) }}
</span>
</label>
<textarea
v-model="refreshTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
></textarea>
<p
v-if="parsedRefreshTokenCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedRefreshTokenCount }) }}
</p>
</div>
<!-- Error Message -->
<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>
<!-- Validate Button -->
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !refreshTokenInput.trim()"
@click="handleValidateRefreshToken"
>
<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>
<!-- 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" />
{{ t(getOAuthKey('sessionTokenRawLabel')) }}
<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('sessionTokenRawPlaceholder'))"
></textarea>
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
{{ t(getOAuthKey('sessionTokenRawHint')) }}
</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
class="btn btn-secondary px-2 py-1 text-xs"
@click="handleOpenSoraSessionUrl"
>
{{ t(getOAuthKey('openSessionUrl')) }}
</button>
<button
type="button"
class="btn btn-secondary px-2 py-1 text-xs"
@click="handleCopySoraSessionUrl"
>
{{ t(getOAuthKey('copySessionUrl')) }}
</button>
</div>
<p class="mt-1 break-all text-xs text-blue-600 dark:text-blue-400">
{{ soraSessionUrl }}
</p>
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t(getOAuthKey('sessionUrlHint')) }}
</p>
<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="sessionTokenInput.trim()" class="mb-4 space-y-3">
<div>
<label
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
>
{{ t(getOAuthKey('parsedSessionTokensLabel')) }}
<span
v-if="parsedSessionTokenCount > 0"
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
>
{{ parsedSessionTokenCount }}
</span>
</label>
<textarea
:value="parsedSessionTokensText"
rows="2"
readonly
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
></textarea>
<p
v-if="parsedSessionTokenCount === 0"
class="mt-1 text-xs text-amber-600 dark:text-amber-400"
>
{{ t(getOAuthKey('parsedSessionTokensEmpty')) }}
</p>
</div>
<div>
<label
class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300"
>
{{ t(getOAuthKey('parsedAccessTokensLabel')) }}
<span
v-if="parsedAccessTokenFromSessionInputCount > 0"
class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white"
>
{{ parsedAccessTokenFromSessionInputCount }}
</span>
</label>
<textarea
:value="parsedAccessTokensText"
rows="2"
readonly
class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700"
></textarea>
</div>
</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 || parsedSessionTokenCount === 0"
@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>
<!-- Access Token Input (Sora) -->
<div v-if="inputMethod === 'access_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('admin.accounts.oauth.openai.accessTokenDesc', '直接粘贴 Access Token 创建账号,无需 OAuth 授权流程。支持批量导入(每行一个)。') }}
</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" />
Access Token
<span
v-if="parsedAccessTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedAccessTokenCount }) }}
</span>
</label>
<textarea
v-model="accessTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t('admin.accounts.oauth.openai.accessTokenPlaceholder', '粘贴 Access Token每行一个')"
></textarea>
<p
v-if="parsedAccessTokenCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedAccessTokenCount }) }}
</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 || !accessTokenInput.trim()"
@click="handleImportAccessToken"
>
<Icon name="sparkles" size="sm" class="mr-2" />
{{ t('admin.accounts.oauth.openai.importAccessToken', '导入 Access Token') }}
</button>
</div>
</div>
@@ -173,7 +501,7 @@
</div>
<!-- Manual Authorization Flow -->
<div v-else class="space-y-4">
<div v-if="inputMethod === 'manual'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
{{ oauthFollowSteps }}
</p>
@@ -414,8 +742,10 @@
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@/composables/useClipboard'
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
import Icon from '@/components/icons/Icon.vue'
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
import type { AccountPlatform } from '@/types'
interface Props {
addMethod: AddMethod
@@ -428,7 +758,10 @@ interface Props {
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
showAccessTokenOption?: boolean // Whether to show access token input option (Sora only)
platform?: AccountPlatform // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
@@ -442,6 +775,9 @@ const props = withDefaults(defineProps<Props>(), {
allowMultiple: false,
methodLabel: 'Authorization Method',
showCookieOption: true,
showRefreshTokenOption: false,
showSessionTokenOption: false,
showAccessTokenOption: false,
platform: 'anthropic',
showProjectId: true
})
@@ -450,16 +786,19 @@ const emit = defineEmits<{
'generate-url': []
'exchange-code': [code: string]
'cookie-auth': [sessionKey: string]
'validate-refresh-token': [refreshToken: string]
'validate-session-token': [sessionToken: string]
'import-access-token': [accessToken: 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}`
@@ -478,7 +817,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 ''
})
@@ -487,10 +826,16 @@ const oauthImportantNotice = computed(() => {
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const refreshTokenInput = ref('')
const sessionTokenInput = ref('')
const accessTokenInput = 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 || props.showSessionTokenOption || props.showAccessTokenOption)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
@@ -502,6 +847,41 @@ const parsedKeyCount = computed(() => {
.filter((k) => k).length
})
// Computed: count of refresh tokens entered
const parsedRefreshTokenCount = computed(() => {
return refreshTokenInput.value
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt).length
})
const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value))
const parsedSessionTokenCount = computed(() => {
return parsedSoraRawTokens.value.sessionTokens.length
})
const parsedSessionTokensText = computed(() => {
return parsedSoraRawTokens.value.sessionTokens.join('\n')
})
const parsedAccessTokenFromSessionInputCount = computed(() => {
return parsedSoraRawTokens.value.accessTokens.length
})
const parsedAccessTokensText = computed(() => {
return parsedSoraRawTokens.value.accessTokens.join('\n')
})
const soraSessionUrl = 'https://sora.chatgpt.com/api/auth/session'
const parsedAccessTokenCount = computed(() => {
return accessTokenInput.value
.split('\n')
.map((at) => at.trim())
.filter((at) => at).length
})
// Watchers
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
@@ -510,7 +890,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
@@ -520,7 +900,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) {
@@ -531,7 +911,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) {
@@ -563,18 +943,48 @@ const handleCookieAuth = () => {
}
}
const handleValidateRefreshToken = () => {
if (refreshTokenInput.value.trim()) {
emit('validate-refresh-token', refreshTokenInput.value.trim())
}
}
const handleValidateSessionToken = () => {
if (parsedSessionTokenCount.value > 0) {
emit('validate-session-token', parsedSessionTokensText.value)
}
}
const handleOpenSoraSessionUrl = () => {
window.open(soraSessionUrl, '_blank', 'noopener,noreferrer')
}
const handleCopySoraSessionUrl = () => {
copyToClipboard(soraSessionUrl, 'URL copied to clipboard')
}
const handleImportAccessToken = () => {
if (accessTokenInput.value.trim()) {
emit('import-access-token', accessTokenInput.value.trim())
}
}
// Expose methods and state
defineExpose({
authCode: authCodeInput,
oauthState,
projectId,
sessionKey: sessionKeyInput,
refreshToken: refreshTokenInput,
sessionToken: sessionTokenInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
oauthState.value = ''
projectId.value = ''
sessionKeyInput.value = ''
refreshTokenInput.value = ''
sessionTokenInput.value = ''
inputMethod.value = 'manual'
showHelpDialog.value = false
}

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
modelValue: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
const enabled = ref(props.modelValue != null && props.modelValue > 0)
// Sync enabled state when modelValue changes externally (e.g. account load)
watch(
() => props.modelValue,
(val) => {
enabled.value = val != null && val > 0
}
)
// When toggle is turned off, clear the value
watch(enabled, (val) => {
if (!val) {
emit('update:modelValue', null)
}
})
const onInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:modelValue', Number.isNaN(raw) ? null : raw)
}
</script>
<template>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitHint') }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.quotaLimitToggle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitToggleHint') }}
</p>
</div>
<button
type="button"
@click="enabled = !enabled"
: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',
enabled ? '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',
enabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="enabled" class="space-y-3">
<div>
<label class="input-label">{{ t('admin.accounts.quotaLimitAmount') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
:value="modelValue"
@input="onInput"
type="number"
min="0"
step="0.01"
class="input pl-7"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaLimitAmountHint') }}</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -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 = ''

View File

@@ -6,15 +6,20 @@
close-on-click-outside
@close="handleClose"
>
<form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
<!-- Step 1: Input credentials -->
<form
v-if="currentStep === 'input'"
id="sync-from-crs-form"
class="space-y-4"
@submit.prevent="handlePreview"
>
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }}
</div>
<div
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
已有账号仅同步 CRS
返回的字段缺失字段保持原值凭据按键合并不会清空未下发的键未勾选"同步代理"时保留原有代理
{{ t('admin.accounts.crsUpdateBehaviorNote') }}
</div>
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
@@ -24,26 +29,30 @@
<div class="grid grid-cols-1 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
<label for="crs-base-url" class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
<input
id="crs-base-url"
v-model="form.base_url"
type="text"
class="input"
required
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input v-model="form.username" type="text" class="input" autocomplete="username" />
<label for="crs-username" class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input id="crs-username" v-model="form.username" type="text" class="input" required autocomplete="username" />
</div>
<div>
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
<label for="crs-password" class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
<input
id="crs-password"
v-model="form.password"
type="password"
class="input"
required
autocomplete="current-password"
/>
</div>
@@ -58,9 +67,101 @@
{{ t('admin.accounts.syncProxies') }}
</label>
</div>
</form>
<!-- Step 2: Preview & select -->
<div v-else-if="currentStep === 'preview' && previewResult" class="space-y-4">
<!-- Existing accounts (read-only info) -->
<div
v-if="previewResult.existing_accounts.length"
class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60"
>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-dark-300">
{{ t('admin.accounts.crsExistingAccounts') }}
<span class="ml-1 text-xs text-gray-400">({{ previewResult.existing_accounts.length }})</span>
</div>
<div class="max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400">
<div
v-for="acc in previewResult.existing_accounts"
:key="acc.crs_account_id"
class="flex items-center gap-2 py-0.5"
>
<span
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>{{ acc.platform }} / {{ acc.type }}</span>
<span class="truncate">{{ acc.name }}</span>
</div>
</div>
</div>
<!-- New accounts (selectable) -->
<div v-if="previewResult.new_accounts.length">
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.crsNewAccounts') }}
<span class="ml-1 text-xs text-gray-400">({{ previewResult.new_accounts.length }})</span>
</div>
<div class="flex gap-2">
<button
type="button"
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@click="selectAll"
>{{ t('admin.accounts.crsSelectAll') }}</button>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400"
@click="selectNone"
>{{ t('admin.accounts.crsSelectNone') }}</button>
</div>
</div>
<div
class="max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600"
>
<label
v-for="acc in previewResult.new_accounts"
:key="acc.crs_account_id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40"
>
<input
type="checkbox"
:checked="selectedIds.has(acc.crs_account_id)"
class="rounded border-gray-300 dark:border-dark-600"
@change="toggleSelect(acc.crs_account_id)"
/>
<span
class="inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>{{ acc.platform }} / {{ acc.type }}</span>
<span class="truncate text-sm text-gray-700 dark:text-dark-300">{{ acc.name }}</span>
</label>
</div>
<div class="mt-1 text-xs text-gray-400">
{{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }}
</div>
</div>
<!-- Sync options summary -->
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
<span>{{ t('admin.accounts.syncProxies') }}:</span>
<span :class="form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'">
{{ form.sync_proxies ? t('common.yes') : t('common.no') }}
</span>
</div>
<!-- No new accounts -->
<div
v-if="!previewResult.new_accounts.length"
class="rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
{{ t('admin.accounts.crsNoNewAccounts') }}
<span v-if="previewResult.existing_accounts.length">
{{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }}
</span>
</div>
</div>
<!-- Step 3: Result -->
<div v-else-if="currentStep === 'result' && result" class="space-y-4">
<div
v-if="result"
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
@@ -84,21 +185,56 @@
</div>
</div>
</div>
</form>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="syncing"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button>
<!-- Step 1: Input -->
<template v-if="currentStep === 'input'">
<button
class="btn btn-secondary"
type="button"
:disabled="previewing"
@click="handleClose"
>
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="previewing"
>
{{ previewing ? t('admin.accounts.crsPreviewing') : t('admin.accounts.crsPreview') }}
</button>
</template>
<!-- Step 2: Preview -->
<template v-else-if="currentStep === 'preview'">
<button
class="btn btn-secondary"
type="button"
:disabled="syncing"
@click="handleBack"
>
{{ t('admin.accounts.crsBack') }}
</button>
<button
class="btn btn-primary"
type="button"
:disabled="syncing || hasNewButNoneSelected"
@click="handleSync"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button>
</template>
<!-- Step 3: Result -->
<template v-else-if="currentStep === 'result'">
<button class="btn btn-secondary" type="button" @click="handleClose">
{{ t('common.close') }}
</button>
</template>
</div>
</template>
</BaseDialog>
@@ -110,6 +246,7 @@ import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { PreviewFromCRSResult } from '@/api/admin/accounts'
interface Props {
show: boolean
@@ -126,7 +263,12 @@ const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
type Step = 'input' | 'preview' | 'result'
const currentStep = ref<Step>('input')
const previewing = ref(false)
const syncing = ref(false)
const previewResult = ref<PreviewFromCRSResult | null>(null)
const selectedIds = ref(new Set<string>())
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
const form = reactive({
@@ -136,28 +278,90 @@ const form = reactive({
sync_proxies: true
})
const hasNewButNoneSelected = computed(() => {
if (!previewResult.value) return false
return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0
})
const errorItems = computed(() => {
if (!result.value?.items) return []
return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped')
return result.value.items.filter(
(i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected')
)
})
watch(
() => props.show,
(open) => {
if (open) {
currentStep.value = 'input'
previewResult.value = null
selectedIds.value = new Set()
result.value = null
form.base_url = ''
form.username = ''
form.password = ''
form.sync_proxies = true
}
}
)
const handleClose = () => {
// 防止在同步进行中关闭对话框
if (syncing.value) {
if (syncing.value || previewing.value) {
return
}
emit('close')
}
const handleBack = () => {
currentStep.value = 'input'
previewResult.value = null
selectedIds.value = new Set()
}
const selectAll = () => {
if (!previewResult.value) return
selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id))
}
const selectNone = () => {
selectedIds.value = new Set()
}
const toggleSelect = (id: string) => {
const s = new Set(selectedIds.value)
if (s.has(id)) {
s.delete(id)
} else {
s.add(id)
}
selectedIds.value = s
}
const handlePreview = async () => {
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
appStore.showError(t('admin.accounts.syncMissingFields'))
return
}
previewing.value = true
try {
const res = await adminAPI.accounts.previewFromCrs({
base_url: form.base_url.trim(),
username: form.username.trim(),
password: form.password
})
previewResult.value = res
// Auto-select all new accounts
selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id))
currentStep.value = 'preview'
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed'))
} finally {
previewing.value = false
}
}
const handleSync = async () => {
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
appStore.showError(t('admin.accounts.syncMissingFields'))
@@ -170,16 +374,18 @@ const handleSync = async () => {
base_url: form.base_url.trim(),
username: form.username.trim(),
password: form.password,
sync_proxies: form.sync_proxies
sync_proxies: form.sync_proxies,
selected_account_ids: [...selectedIds.value]
})
result.value = res
currentStep.value = 'result'
if (res.failed > 0) {
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
} else {
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
emit('synced')
}
emit('synced')
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
} finally {

View File

@@ -0,0 +1,125 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountUsageCell from '../AccountUsageCell.vue'
const { getUsage } = vi.hoisted(() => ({
getUsage: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
getUsage
}
}
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
describe('AccountUsageCell', () => {
beforeEach(() => {
getUsage.mockReset()
})
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
getUsage.mockResolvedValue({
antigravity_quota: {
'gemini-3.1-flash-image': {
utilization: 20,
reset_time: '2026-03-01T10:00:00Z'
},
'gemini-3-pro-image': {
utilization: 70,
reset_time: '2026-03-01T09:00:00Z'
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: {
id: 1001,
platform: 'antigravity',
type: 'oauth',
extra: {}
} as any
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z')
})
it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 0,
resets_at: null,
remaining_seconds: 0,
window_stats: {
requests: 2,
tokens: 27700,
cost: 0.06,
standard_cost: 0.06,
user_cost: 0.06
}
},
seven_day: {
utilization: 0,
resets_at: null,
remaining_seconds: 0,
window_stats: {
requests: 2,
tokens: 27700,
cost: 0.06,
standard_cost: 0.06,
user_cost: 0.06
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: {
id: 2002,
platform: 'openai',
type: 'oauth',
extra: {}
} as any
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2002)
expect(wrapper.text()).toContain('5h|0|27700')
expect(wrapper.text()).toContain('7d|0|27700')
})
})

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import BulkEditAccountModal from '../BulkEditAccountModal.vue'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
bulkEdit: vi.fn()
}
}
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function mountModal() {
return mount(BulkEditAccountModal, {
props: {
show: true,
accountIds: [1, 2],
selectedPlatforms: ['antigravity'],
proxies: [],
groups: []
} as any,
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' },
Select: true,
ProxySelector: true,
GroupSelector: true,
Icon: true
}
}
})
}
describe('BulkEditAccountModal', () => {
it('antigravity 白名单包含 Gemini 图片模型且过滤掉普通 GPT 模型', () => {
const wrapper = mountModal()
expect(wrapper.text()).toContain('Gemini 3.1 Flash Image')
expect(wrapper.text()).toContain('Gemini 3 Pro Image (Legacy)')
expect(wrapper.text()).not.toContain('GPT-5.3 Codex')
})
it('antigravity 映射预设包含图片映射并过滤 OpenAI 预设', async () => {
const wrapper = mountModal()
const mappingTab = wrapper.findAll('button').find((btn) => btn.text().includes('admin.accounts.modelMapping'))
expect(mappingTab).toBeTruthy()
await mappingTab!.trigger('click')
expect(wrapper.text()).toContain('Gemini 3.1 Image')
expect(wrapper.text()).toContain('G3 Image→3.1')
expect(wrapper.text()).not.toContain('GPT-5.3 Codex')
})
})

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { applyInterceptWarmup } from '../credentialsBuilder'
describe('applyInterceptWarmup', () => {
it('create + enabled=true: should set intercept_warmup_requests to true', () => {
const creds: Record<string, unknown> = { access_token: 'tok' }
applyInterceptWarmup(creds, true, 'create')
expect(creds.intercept_warmup_requests).toBe(true)
})
it('create + enabled=false: should not add the field', () => {
const creds: Record<string, unknown> = { access_token: 'tok' }
applyInterceptWarmup(creds, false, 'create')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('edit + enabled=true: should set intercept_warmup_requests to true', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyInterceptWarmup(creds, true, 'edit')
expect(creds.intercept_warmup_requests).toBe(true)
})
it('edit + enabled=false + field exists: should delete the field', () => {
const creds: Record<string, unknown> = { api_key: 'sk', intercept_warmup_requests: true }
applyInterceptWarmup(creds, false, 'edit')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('edit + enabled=false + field absent: should not throw', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyInterceptWarmup(creds, false, 'edit')
expect('intercept_warmup_requests' in creds).toBe(false)
})
it('should not affect other fields', () => {
const creds: Record<string, unknown> = {
api_key: 'sk',
base_url: 'url',
intercept_warmup_requests: true
}
applyInterceptWarmup(creds, false, 'edit')
expect(creds.api_key).toBe('sk')
expect(creds.base_url).toBe('url')
expect('intercept_warmup_requests' in creds).toBe(false)
})
})

View File

@@ -0,0 +1,11 @@
export function applyInterceptWarmup(
credentials: Record<string, unknown>,
enabled: boolean,
mode: 'create' | 'edit'
): void {
if (enabled) {
credentials.intercept_warmup_requests = true
} else if (mode === 'edit') {
delete credentials.intercept_warmup_requests
}
}

View File

@@ -148,6 +148,16 @@
{{ rule.passthrough_body ? t('admin.errorPassthrough.passthrough') : t('admin.errorPassthrough.custom') }}
</span>
</div>
<div v-if="rule.skip_monitoring" class="flex items-center gap-1">
<Icon
name="checkCircle"
size="xs"
class="text-yellow-500"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.errorPassthrough.skipMonitoring') }}
</span>
</div>
</div>
</td>
<td class="px-3 py-2">
@@ -366,6 +376,19 @@
</div>
</div>
<!-- Skip Monitoring -->
<div class="flex items-center gap-1.5">
<input
type="checkbox"
v-model="form.skip_monitoring"
class="h-3.5 w-3.5 rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
/>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.errorPassthrough.form.skipMonitoring') }}
</span>
</div>
<p class="input-hint text-xs -mt-3">{{ t('admin.errorPassthrough.form.skipMonitoringHint') }}</p>
<!-- Enabled -->
<div class="flex items-center gap-1.5">
<input
@@ -453,6 +476,7 @@ const form = reactive({
response_code: null as number | null,
passthrough_body: true,
custom_message: null as string | null,
skip_monitoring: false,
description: null as string | null
})
@@ -497,6 +521,7 @@ const resetForm = () => {
form.response_code = null
form.passthrough_body = true
form.custom_message = null
form.skip_monitoring = false
form.description = null
errorCodesInput.value = ''
keywordsInput.value = ''
@@ -520,6 +545,7 @@ const handleEdit = (rule: ErrorPassthroughRule) => {
form.response_code = rule.response_code
form.passthrough_body = rule.passthrough_body
form.custom_message = rule.custom_message
form.skip_monitoring = rule.skip_monitoring
form.description = rule.description
errorCodesInput.value = rule.error_codes.join(', ')
keywordsInput.value = rule.keywords.join('\n')
@@ -575,6 +601,7 @@ const handleSubmit = async () => {
response_code: form.passthrough_code ? null : form.response_code,
passthrough_body: form.passthrough_body,
custom_message: form.passthrough_body ? null : form.custom_message,
skip_monitoring: form.skip_monitoring,
description: form.description?.trim() || null
}

View File

@@ -18,6 +18,10 @@
<Icon name="chart" size="sm" class="text-indigo-500" />
{{ t('admin.accounts.viewStats') }}
</button>
<button @click="$emit('schedule', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="clock" size="sm" class="text-orange-500" />
{{ t('admin.scheduledTests.schedule') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
@@ -37,6 +41,10 @@
<Icon name="clock" size="sm" />
{{ t('admin.accounts.clearRateLimit') }}
</button>
<button v-if="hasQuotaLimit" @click="$emit('reset-quota', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-teal-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
{{ t('admin.accounts.resetQuota') }}
</button>
</template>
</div>
</div>
@@ -51,10 +59,28 @@ import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit', 'reset-quota'])
const { t } = useI18n()
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
return true
}
const modelLimits = (props.account?.extra as Record<string, unknown> | undefined)?.model_rate_limits as
| Record<string, { rate_limit_reset_at: string }>
| undefined
if (modelLimits) {
const now = new Date()
return Object.values(modelLimits).some(info => new Date(info.rate_limit_reset_at) > now)
}
return false
})
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
const hasQuotaLimit = computed(() => {
return props.account?.type === 'apikey' &&
props.account?.quota_limit !== undefined &&
props.account?.quota_limit !== null &&
props.account?.quota_limit > 0
})
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') emit('close')

View File

@@ -10,16 +10,21 @@
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
<Select :model-value="filters.group" class="w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
import type { AdminGroup } from '@/types'
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
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 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 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' }, { 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') }])
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') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
</script>

View File

@@ -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

View File

@@ -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({

View File

@@ -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 =

View File

@@ -0,0 +1,587 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.scheduledTests.title')"
width="wide"
@close="emit('close')"
>
<div class="space-y-4">
<!-- Add Plan Button -->
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.scheduledTests.title') }}
</p>
<button
@click="showAddForm = !showAddForm"
class="btn btn-primary flex items-center gap-1.5 text-sm"
>
<Icon name="plus" size="sm" :stroke-width="2" />
{{ t('admin.scheduledTests.addPlan') }}
</button>
</div>
<!-- Add Plan Form -->
<div
v-if="showAddForm"
class="rounded-xl border border-primary-200 bg-primary-50/50 p-4 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.scheduledTests.addPlan') }}
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.model') }}
</label>
<Select
v-model="newPlan.model_id"
:options="modelOptions"
:placeholder="t('admin.scheduledTests.model')"
:searchable="modelOptions.length > 5"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.cronExpression') }}
</label>
<Input
v-model="newPlan.cron_expression"
:placeholder="'*/30 * * * *'"
:hint="t('admin.scheduledTests.cronHelp')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.maxResults') }}
</label>
<Input
v-model="newPlan.max_results"
type="number"
placeholder="100"
/>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="newPlan.enabled" />
{{ t('admin.scheduledTests.enabled') }}
</label>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
@click="showAddForm = false; resetNewPlan()"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleCreate"
:disabled="!newPlan.model_id || !newPlan.cron_expression || creating"
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon v-if="creating" name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
{{ t('common.save') }}
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<Icon name="refresh" size="md" class="animate-spin text-gray-400" :stroke-width="2" />
<span class="ml-2 text-sm text-gray-500">{{ t('common.loading') }}...</span>
</div>
<!-- Empty State -->
<div
v-else-if="plans.length === 0"
class="rounded-xl border border-dashed border-gray-300 py-10 text-center dark:border-dark-600"
>
<Icon name="calendar" size="lg" class="mx-auto mb-2 text-gray-400" :stroke-width="1.5" />
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.scheduledTests.noPlans') }}
</p>
</div>
<!-- Plans List -->
<div v-else class="space-y-3">
<div
v-for="plan in plans"
:key="plan.id"
class="rounded-xl border border-gray-200 bg-white transition-all dark:border-dark-600 dark:bg-dark-800"
>
<!-- Plan Header -->
<div
class="flex cursor-pointer items-center justify-between px-4 py-3"
@click="toggleExpand(plan.id)"
>
<div class="flex flex-1 items-center gap-4">
<!-- Model -->
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ plan.model_id }}
</div>
<div class="mt-0.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{{ plan.cron_expression }}
</div>
</div>
<!-- Enabled Toggle -->
<div class="flex items-center gap-1.5" @click.stop>
<Toggle
:model-value="plan.enabled"
@update:model-value="(val: boolean) => handleToggleEnabled(plan, val)"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ plan.enabled ? t('admin.scheduledTests.enabled') : '' }}
</span>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Last Run -->
<div v-if="plan.last_run_at" class="hidden text-right text-xs text-gray-500 dark:text-gray-400 sm:block">
<div>{{ t('admin.scheduledTests.lastRun') }}</div>
<div>{{ formatDateTime(plan.last_run_at) }}</div>
</div>
<!-- Next Run -->
<div v-if="plan.next_run_at" class="hidden text-right text-xs text-gray-500 dark:text-gray-400 sm:block">
<div>{{ t('admin.scheduledTests.nextRun') }}</div>
<div>{{ formatDateTime(plan.next_run_at) }}</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1" @click.stop>
<button
@click="startEdit(plan)"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-blue-50 hover:text-blue-500 dark:hover:bg-blue-900/20"
:title="t('admin.scheduledTests.editPlan')"
>
<Icon name="edit" size="sm" :stroke-width="2" />
</button>
<button
@click="confirmDeletePlan(plan)"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
:title="t('admin.scheduledTests.deletePlan')"
>
<Icon name="trash" size="sm" :stroke-width="2" />
</button>
</div>
<!-- Expand indicator -->
<Icon
name="chevronDown"
size="sm"
:class="[
'text-gray-400 transition-transform duration-200',
expandedPlanId === plan.id ? 'rotate-180' : ''
]"
/>
</div>
</div>
<!-- Edit Form -->
<div
v-if="editingPlanId === plan.id"
class="border-t border-blue-100 bg-blue-50/50 px-4 py-3 dark:border-blue-900 dark:bg-blue-900/10"
@click.stop
>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.editPlan') }}
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.model') }}
</label>
<Select
v-model="editForm.model_id"
:options="modelOptions"
:placeholder="t('admin.scheduledTests.model')"
:searchable="modelOptions.length > 5"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.cronExpression') }}
</label>
<Input
v-model="editForm.cron_expression"
:placeholder="'*/30 * * * *'"
:hint="t('admin.scheduledTests.cronHelp')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.maxResults') }}
</label>
<Input
v-model="editForm.max_results"
type="number"
placeholder="100"
/>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Toggle v-model="editForm.enabled" />
{{ t('admin.scheduledTests.enabled') }}
</label>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
@click="cancelEdit"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleEdit"
:disabled="!editForm.model_id || !editForm.cron_expression || updating"
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon v-if="updating" name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
{{ t('common.save') }}
</button>
</div>
</div>
<!-- Expanded Results Section -->
<div
v-if="expandedPlanId === plan.id"
class="border-t border-gray-100 px-4 py-3 dark:border-dark-700"
>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.scheduledTests.results') }}
</div>
<!-- Results Loading -->
<div v-if="loadingResults" class="flex items-center justify-center py-4">
<Icon name="refresh" size="sm" class="animate-spin text-gray-400" :stroke-width="2" />
<span class="ml-2 text-xs text-gray-500">{{ t('common.loading') }}...</span>
</div>
<!-- No Results -->
<div
v-else-if="results.length === 0"
class="py-4 text-center text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.scheduledTests.noResults') }}
</div>
<!-- Results List -->
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
<div
v-for="result in results"
:key="result.id"
class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Status Badge -->
<span
:class="[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
result.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: result.status === 'running'
? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400'
]"
>
{{
result.status === 'success'
? t('admin.scheduledTests.success')
: result.status === 'running'
? t('admin.scheduledTests.running')
: t('admin.scheduledTests.failed')
}}
</span>
<!-- Latency -->
<span v-if="result.latency_ms > 0" class="text-xs text-gray-500 dark:text-gray-400">
{{ result.latency_ms }}ms
</span>
</div>
<!-- Started At -->
<span class="text-xs text-gray-400">
{{ formatDateTime(result.started_at) }}
</span>
</div>
<!-- Response / Error (collapsible) -->
<div v-if="result.error_message" class="mt-2">
<div
class="cursor-pointer text-xs font-medium text-red-600 dark:text-red-400"
@click="toggleResultDetail(result.id)"
>
{{ t('admin.scheduledTests.errorMessage') }}
<Icon
name="chevronDown"
size="sm"
:class="[
'inline transition-transform duration-200',
expandedResultIds.has(result.id) ? 'rotate-180' : ''
]"
/>
</div>
<pre
v-if="expandedResultIds.has(result.id)"
class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-red-50 p-2 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300"
>{{ result.error_message }}</pre>
</div>
<div v-else-if="result.response_text" class="mt-2">
<div
class="cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-400"
@click="toggleResultDetail(result.id)"
>
{{ t('admin.scheduledTests.responseText') }}
<Icon
name="chevronDown"
size="sm"
:class="[
'inline transition-transform duration-200',
expandedResultIds.has(result.id) ? 'rotate-180' : ''
]"
/>
</div>
<pre
v-if="expandedResultIds.has(result.id)"
class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-gray-100 p-2 text-xs text-gray-700 dark:bg-dark-800 dark:text-gray-300"
>{{ result.response_text }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteConfirm"
:title="t('admin.scheduledTests.deletePlan')"
:message="t('admin.scheduledTests.confirmDelete')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleDelete"
@cancel="showDeleteConfirm = false"
/>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import Input from '@/components/common/Input.vue'
import Toggle from '@/components/common/Toggle.vue'
import { Icon } from '@/components/icons'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format'
import type { ScheduledTestPlan, ScheduledTestResult } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
const props = defineProps<{
show: boolean
accountId: number | null
modelOptions: SelectOption[]
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
// State
const loading = ref(false)
const creating = ref(false)
const loadingResults = ref(false)
const plans = ref<ScheduledTestPlan[]>([])
const results = ref<ScheduledTestResult[]>([])
const expandedPlanId = ref<number | null>(null)
const expandedResultIds = reactive(new Set<number>())
const showAddForm = ref(false)
const showDeleteConfirm = ref(false)
const deletingPlan = ref<ScheduledTestPlan | null>(null)
const editingPlanId = ref<number | null>(null)
const updating = ref(false)
const editForm = reactive({
model_id: '' as string,
cron_expression: '' as string,
max_results: '100' as string,
enabled: true
})
const newPlan = reactive({
model_id: '' as string,
cron_expression: '' as string,
max_results: '100' as string,
enabled: true
})
const resetNewPlan = () => {
newPlan.model_id = ''
newPlan.cron_expression = ''
newPlan.max_results = '100'
newPlan.enabled = true
}
// Load plans when dialog opens
watch(
() => props.show,
async (visible) => {
if (visible && props.accountId) {
await loadPlans()
} else {
plans.value = []
results.value = []
expandedPlanId.value = null
expandedResultIds.clear()
showAddForm.value = false
showDeleteConfirm.value = false
}
}
)
const loadPlans = async () => {
if (!props.accountId) return
loading.value = true
try {
plans.value = await adminAPI.scheduledTests.listByAccount(props.accountId)
} catch (error: any) {
appStore.showError(error?.message || 'Failed to load plans')
} finally {
loading.value = false
}
}
const handleCreate = async () => {
if (!props.accountId || !newPlan.model_id || !newPlan.cron_expression) return
creating.value = true
try {
const maxResults = Number(newPlan.max_results) || 100
await adminAPI.scheduledTests.create({
account_id: props.accountId,
model_id: newPlan.model_id,
cron_expression: newPlan.cron_expression,
enabled: newPlan.enabled,
max_results: maxResults
})
appStore.showSuccess(t('admin.scheduledTests.createSuccess'))
showAddForm.value = false
resetNewPlan()
await loadPlans()
} catch (error: any) {
appStore.showError(error?.message || 'Failed to create plan')
} finally {
creating.value = false
}
}
const handleToggleEnabled = async (plan: ScheduledTestPlan, enabled: boolean) => {
try {
const updated = await adminAPI.scheduledTests.update(plan.id, { enabled })
const index = plans.value.findIndex((p) => p.id === plan.id)
if (index !== -1) {
plans.value[index] = updated
}
appStore.showSuccess(t('admin.scheduledTests.updateSuccess'))
} catch (error: any) {
appStore.showError(error?.message || 'Failed to update plan')
}
}
const startEdit = (plan: ScheduledTestPlan) => {
editingPlanId.value = plan.id
editForm.model_id = plan.model_id
editForm.cron_expression = plan.cron_expression
editForm.max_results = String(plan.max_results)
editForm.enabled = plan.enabled
}
const cancelEdit = () => {
editingPlanId.value = null
}
const handleEdit = async () => {
if (!editingPlanId.value || !editForm.model_id || !editForm.cron_expression) return
updating.value = true
try {
const updated = await adminAPI.scheduledTests.update(editingPlanId.value, {
model_id: editForm.model_id,
cron_expression: editForm.cron_expression,
max_results: Number(editForm.max_results) || 100,
enabled: editForm.enabled
})
const index = plans.value.findIndex((p) => p.id === editingPlanId.value)
if (index !== -1) {
plans.value[index] = updated
}
appStore.showSuccess(t('admin.scheduledTests.updateSuccess'))
editingPlanId.value = null
} catch (error: any) {
appStore.showError(error?.message || 'Failed to update plan')
} finally {
updating.value = false
}
}
const confirmDeletePlan = (plan: ScheduledTestPlan) => {
deletingPlan.value = plan
showDeleteConfirm.value = true
}
const handleDelete = async () => {
if (!deletingPlan.value) return
try {
await adminAPI.scheduledTests.delete(deletingPlan.value.id)
appStore.showSuccess(t('admin.scheduledTests.deleteSuccess'))
plans.value = plans.value.filter((p) => p.id !== deletingPlan.value!.id)
if (expandedPlanId.value === deletingPlan.value.id) {
expandedPlanId.value = null
results.value = []
}
} catch (error: any) {
appStore.showError(error?.message || 'Failed to delete plan')
} finally {
showDeleteConfirm.value = false
deletingPlan.value = null
}
}
const toggleExpand = async (planId: number) => {
if (expandedPlanId.value === planId) {
expandedPlanId.value = null
results.value = []
expandedResultIds.clear()
return
}
expandedPlanId.value = planId
expandedResultIds.clear()
loadingResults.value = true
try {
results.value = await adminAPI.scheduledTests.listResults(planId, 20)
} catch (error: any) {
appStore.showError(error?.message || 'Failed to load results')
results.value = []
} finally {
loadingResults.value = false
}
}
const toggleResultDetail = (resultId: number) => {
if (expandedResultIds.has(resultId)) {
expandedResultIds.delete(resultId)
} else {
expandedResultIds.add(resultId)
}
}
</script>

View File

@@ -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 })

View File

@@ -125,6 +125,7 @@ import Pagination from '@/components/common/Pagination.vue'
import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import { adminUsageAPI } from '@/api/admin/usage'
import type { AdminUsageQueryParams, UsageCleanupTask, CreateUsageCleanupTaskRequest } from '@/api/admin/usage'
import { requestTypeToLegacyStream } from '@/utils/usageRequestType'
interface Props {
show: boolean
@@ -310,7 +311,13 @@ const buildPayload = (): CreateUsageCleanupTaskRequest | null => {
if (localFilters.value.model) {
payload.model = localFilters.value.model
}
if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
if (localFilters.value.request_type) {
payload.request_type = localFilters.value.request_type
const legacyStream = requestTypeToLegacyStream(localFilters.value.request_type)
if (legacyStream !== null && legacyStream !== undefined) {
payload.stream = legacyStream
}
} else if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
payload.stream = localFilters.value.stream
}
if (localFilters.value.billing_type !== null && localFilters.value.billing_type !== undefined) {

View File

@@ -121,10 +121,10 @@
</div>
</div>
<!-- Stream Type Filter -->
<!-- Request Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
<Select v-model="filters.request_type" :options="requestTypeOptions" @change="emitChange" />
</div>
<!-- Billing Type Filter -->
@@ -160,6 +160,7 @@
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
{{ t('common.reset') }}
</button>
<slot name="after-reset" />
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
{{ t('admin.usage.cleanup.button') }}
</button>
@@ -232,10 +233,11 @@ let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
const streamTypeOptions = ref<SelectOption[]>([
const requestTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allTypes') },
{ value: true, label: t('usage.stream') },
{ value: false, label: t('usage.sync') }
{ value: 'ws_v2', label: t('usage.ws') },
{ value: 'stream', label: t('usage.stream') },
{ value: 'sync', label: t('usage.sync') }
])
const billingTypeOptions = ref<SelectOption[]>([

View File

@@ -1,7 +1,7 @@
<template>
<div class="card overflow-hidden">
<div class="overflow-auto">
<DataTable :columns="cols" :data="data" :loading="loading">
<DataTable :columns="columns" :data="data" :loading="loading">
<template #cell-user="{ row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
@@ -35,8 +35,8 @@
</template>
<template #cell-stream="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.stream ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'">
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getRequestTypeBadgeClass(row)">
{{ getRequestTypeLabel(row) }}
</span>
</template>
@@ -70,6 +70,8 @@
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
</div>
@@ -121,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] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
@@ -157,9 +159,36 @@
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
<!-- 5m/1h 明细时展开显示 -->
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation5mTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('admin.usage.cacheCreation1hTokens') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
</div>
</template>
<!-- 无明细时只显示聚合值 -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
<span class="text-gray-400 flex items-center gap-1.5">
{{ t('usage.cacheTtlOverriddenLabel') }}
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
</span>
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
@@ -239,15 +268,16 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types'
defineProps(['data', 'loading'])
defineProps(['data', 'loading', 'columns'])
const { t } = useI18n()
// Tooltip state - cost
@@ -260,23 +290,21 @@ const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<AdminUsageLog | null>(null)
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'account', label: t('admin.usage.account'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
{ key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
])
const getRequestTypeLabel = (row: AdminUsageLog): string => {
const requestType = resolveUsageRequestType(row)
if (requestType === 'ws_v2') return t('usage.ws')
if (requestType === 'stream') return t('usage.stream')
if (requestType === 'sync') return t('usage.sync')
return t('usage.unknown')
}
const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
const requestType = resolveUsageRequestType(row)
if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}
const formatCacheTokens = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
@@ -284,16 +312,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 => {

View File

@@ -1,5 +1,5 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.userApiKeys')" width="wide" @close="$emit('close')">
<BaseDialog :show="show" :title="t('admin.users.userApiKeys')" width="wide" @close="handleClose">
<div v-if="user" class="space-y-4">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
@@ -9,7 +9,7 @@
</div>
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 animate-spin text-primary-500" 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></div>
<div v-else-if="apiKeys.length === 0" class="py-8 text-center"><p class="text-sm text-gray-500">{{ t('admin.users.noApiKeys') }}</p></div>
<div v-else class="max-h-96 space-y-3 overflow-y-auto">
<div v-else ref="scrollContainerRef" class="max-h-96 space-y-3 overflow-y-auto" @scroll="closeGroupSelector">
<div v-for="key in apiKeys" :key="key.id" class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
@@ -18,30 +18,237 @@
</div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-xs text-gray-500">
<div class="flex items-center gap-1"><span>{{ t('admin.users.group') }}: {{ key.group?.name || t('admin.users.none') }}</span></div>
<div class="flex items-center gap-1">
<span>{{ t('admin.users.group') }}:</span>
<button
:ref="(el) => setGroupButtonRef(key.id, el)"
@click="openGroupSelector(key)"
class="-mx-1 -my-0.5 flex cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:disabled="updatingKeyIds.has(key.id)"
>
<GroupBadge
v-if="key.group_id && key.group"
:name="key.group.name"
:platform="key.group.platform"
:subscription-type="key.group.subscription_type"
:rate-multiplier="key.group.rate_multiplier"
/>
<span v-else class="text-gray-400 italic">{{ t('admin.users.none') }}</span>
<svg v-if="updatingKeyIds.has(key.id)" class="h-3 w-3 animate-spin text-primary-500" 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>
<svg v-else class="h-3 w-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /></svg>
</button>
</div>
<div class="flex items-center gap-1"><span>{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span></div>
</div>
</div>
</div>
</div>
</BaseDialog>
<!-- Group Selector Dropdown -->
<Teleport to="body">
<div
v-if="groupSelectorKeyId !== null && dropdownPosition"
ref="dropdownRef"
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div class="max-h-64 overflow-y-auto p-1.5">
<!-- Unbind option -->
<button
@click="changeGroup(selectedKeyForGroup!, null)"
:class="[
'flex w-full items-center rounded-lg px-3 py-2 text-sm transition-colors',
!selectedKeyForGroup?.group_id
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
>
<span class="text-gray-500 italic">{{ t('admin.users.none') }}</span>
<svg
v-if="!selectedKeyForGroup?.group_id"
class="ml-auto h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
</button>
<!-- Group options -->
<button
v-for="group in allGroups"
:key="group.id"
@click="changeGroup(selectedKeyForGroup!, group.id)"
:class="[
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors',
selectedKeyForGroup?.group_id === group.id
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
>
<GroupOptionItem
:name="group.name"
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
:description="group.description"
:selected="selectedKeyForGroup?.group_id === group.id"
/>
</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { AdminUser, ApiKey } from '@/types'
import type { AdminUser, AdminGroup, ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
defineEmits(['close']); const { t } = useI18n()
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
const emit = defineEmits(['close'])
const { t } = useI18n()
const appStore = useAppStore()
watch(() => props.show, (v) => { if (v && props.user) load() })
const load = async () => {
if (!props.user) return; loading.value = true
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
const apiKeys = ref<ApiKey[]>([])
const allGroups = ref<AdminGroup[]>([])
const loading = ref(false)
const updatingKeyIds = ref(new Set<number>())
const groupSelectorKeyId = ref<number | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const scrollContainerRef = ref<HTMLElement | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const selectedKeyForGroup = computed(() => {
if (groupSelectorKeyId.value === null) return null
return apiKeys.value.find((k) => k.id === groupSelectorKeyId.value) || null
})
const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
groupButtonRefs.value.set(keyId, el)
} else {
groupButtonRefs.value.delete(keyId)
}
}
watch(() => props.show, (v) => {
if (v && props.user) {
load()
loadGroups()
} else {
closeGroupSelector()
}
})
const load = async () => {
if (!props.user) return
loading.value = true
groupButtonRefs.value.clear()
try {
const res = await adminAPI.users.getUserApiKeys(props.user.id)
apiKeys.value = res.items || []
} catch (error) {
console.error('Failed to load API keys:', error)
} finally {
loading.value = false
}
}
const loadGroups = async () => {
try {
const groups = await adminAPI.groups.getAll()
// 过滤掉订阅类型分组(需通过订阅管理流程绑定)
allGroups.value = groups.filter((g) => g.subscription_type !== 'subscription')
} catch (error) {
console.error('Failed to load groups:', error)
}
}
const DROPDOWN_HEIGHT = 272 // max-h-64 = 16rem = 256px + padding
const DROPDOWN_GAP = 4
const openGroupSelector = (key: ApiKey) => {
if (groupSelectorKeyId.value === key.id) {
closeGroupSelector()
} else {
const buttonEl = groupButtonRefs.value.get(key.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const openUpward = spaceBelow < DROPDOWN_HEIGHT && rect.top > spaceBelow
dropdownPosition.value = {
top: openUpward ? rect.top - DROPDOWN_HEIGHT - DROPDOWN_GAP : rect.bottom + DROPDOWN_GAP,
left: rect.left
}
}
groupSelectorKeyId.value = key.id
}
}
const closeGroupSelector = () => {
groupSelectorKeyId.value = null
dropdownPosition.value = null
}
const changeGroup = async (key: ApiKey, newGroupId: number | null) => {
closeGroupSelector()
if (key.group_id === newGroupId || (!key.group_id && newGroupId === null)) return
updatingKeyIds.value.add(key.id)
try {
const result = await adminAPI.apiKeys.updateApiKeyGroup(key.id, newGroupId)
// Update local data
const idx = apiKeys.value.findIndex((k) => k.id === key.id)
if (idx !== -1) {
apiKeys.value[idx] = result.api_key
}
if (result.auto_granted_group_access && result.granted_group_name) {
appStore.showSuccess(t('admin.users.groupChangedWithGrant', { group: result.granted_group_name }))
} else {
appStore.showSuccess(t('admin.users.groupChangedSuccess'))
}
} catch (error: any) {
appStore.showError(error?.message || t('admin.users.groupChangeFailed'))
} finally {
updatingKeyIds.value.delete(key.id)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && groupSelectorKeyId.value !== null) {
event.stopPropagation()
closeGroupSelector()
}
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (dropdownRef.value && !dropdownRef.value.contains(target)) {
// Check if the click is on one of the group trigger buttons
for (const el of groupButtonRefs.value.values()) {
if (el.contains(target)) return
}
closeGroupSelector()
}
}
const handleClose = () => {
closeGroupSelector()
emit('close')
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeyDown, true)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown, true)
})
</script>

View File

@@ -13,7 +13,7 @@
</div>
</div>
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ formatBalance(calculateNewBalance()) }}</span></div></div>
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"><div class="flex items-center justify-between text-sm"><span class="text-gray-700 dark:text-gray-300">{{ t('admin.users.newBalance') }}:</span><span class="font-bold text-gray-900 dark:text-gray-100">${{ formatBalance(calculateNewBalance()) }}</span></div></div>
</form>
<template #footer>
<div class="flex justify-end gap-3">

View File

@@ -37,6 +37,14 @@
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.soraStorageQuota') }}</label>
<div class="flex items-center gap-2">
<input v-model.number="form.sora_storage_quota_gb" type="number" min="0" step="0.1" class="input" placeholder="0" />
<span class="shrink-0 text-sm text-gray-500">GB</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.users.soraStorageQuotaHint') }}</p>
</div>
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
</form>
<template #footer>
@@ -66,11 +74,11 @@ const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, sora_storage_quota_gb: 0, customAttributes: {} as UserAttributeValuesMap })
watch(() => props.user, (u) => {
if (u) {
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, sora_storage_quota_gb: Number(((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024)).toFixed(2)), customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
@@ -97,7 +105,7 @@ const handleUpdateUser = async () => {
}
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, sora_storage_quota_bytes: Math.round((form.sora_storage_quota_gb || 0) * 1024 * 1024 * 1024) }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)

View File

@@ -0,0 +1,152 @@
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.groupDistribution') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="groupStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
<th class="pb-2 text-left">{{ t('admin.dashboard.group') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="group in groupStats"
:key="group.group_id"
class="border-t border-gray-100 dark:border-gray-700"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title="group.group_name || String(group.group_id)"
>
{{ group.group_name || t('admin.dashboard.noGroup') }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(group.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(group.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(group.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(group.cost) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { GroupStat } from '@/types'
ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
const props = defineProps<{
groupStats: GroupStat[]
loading?: boolean
}>()
const chartColors = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16'
]
const chartData = computed(() => {
if (!props.groupStats?.length) return null
return {
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
datasets: [
{
data: props.groupStats.map((g) => g.total_tokens),
backgroundColor: chartColors.slice(0, props.groupStats.length),
borderWidth: 0
}
]
}
})
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
}
}
}
}
}))
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
</script>

View File

@@ -63,7 +63,8 @@ const chartColors = computed(() => ({
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
input: '#3b82f6',
output: '#10b981',
cache: '#f59e0b'
cacheCreation: '#f59e0b',
cacheRead: '#06b6d4'
}))
const chartData = computed(() => {
@@ -89,10 +90,18 @@ const chartData = computed(() => {
tension: 0.3
},
{
label: 'Cache',
data: props.trendData.map((d) => d.cache_tokens),
borderColor: chartColors.value.cache,
backgroundColor: `${chartColors.value.cache}20`,
label: 'Cache Creation',
data: props.trendData.map((d) => d.cache_creation_tokens),
borderColor: chartColors.value.cacheCreation,
backgroundColor: `${chartColors.value.cacheCreation}20`,
fill: true,
tension: 0.3
},
{
label: 'Cache Read',
data: props.trendData.map((d) => d.cache_read_tokens),
borderColor: chartColors.value.cacheRead,
backgroundColor: `${chartColors.value.cacheRead}20`,
fill: true,
tension: 0.3
}

View File

@@ -314,16 +314,18 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { announcementsAPI } from '@/api'
import { useAppStore } from '@/stores/app'
import { useAnnouncementStore } from '@/stores/announcements'
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
import type { UserAnnouncement } from '@/types'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const announcementStore = useAnnouncementStore()
// Configure marked
marked.setOptions({
@@ -331,17 +333,14 @@ marked.setOptions({
gfm: true,
})
// State
const announcements = ref<UserAnnouncement[]>([])
// Use store state (storeToRefs for reactivity)
const { announcements, loading } = storeToRefs(announcementStore)
const unreadCount = computed(() => announcementStore.unreadCount)
// Local modal state
const isModalOpen = ref(false)
const detailModalOpen = ref(false)
const selectedAnnouncement = ref<UserAnnouncement | null>(null)
const loading = ref(false)
// Computed
const unreadCount = computed(() =>
announcements.value.filter((a) => !a.read_at).length
)
// Methods
function renderMarkdown(content: string): string {
@@ -350,24 +349,8 @@ function renderMarkdown(content: string): string {
return DOMPurify.sanitize(html)
}
async function loadAnnouncements() {
try {
loading.value = true
const allAnnouncements = await announcementsAPI.list(false)
announcements.value = allAnnouncements.slice(0, 20)
} catch (err: any) {
console.error('Failed to load announcements:', err)
appStore.showError(err?.message || t('common.unknownError'))
} finally {
loading.value = false
}
}
function openModal() {
isModalOpen.value = true
if (announcements.value.length === 0) {
loadAnnouncements()
}
}
function closeModal() {
@@ -389,14 +372,7 @@ function closeDetail() {
async function markAsRead(id: number) {
try {
await announcementsAPI.markRead(id)
const announcement = announcements.value.find((a) => a.id === id)
if (announcement) {
announcement.read_at = new Date().toISOString()
}
if (selectedAnnouncement.value?.id === id) {
selectedAnnouncement.value.read_at = new Date().toISOString()
}
await announcementStore.markAsRead(id)
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
}
@@ -410,19 +386,10 @@ async function markAsReadAndClose(id: number) {
async function markAllAsRead() {
try {
loading.value = true
const unreadAnnouncements = announcements.value.filter((a) => !a.read_at)
await Promise.all(unreadAnnouncements.map((a) => announcementsAPI.markRead(a.id)))
announcements.value.forEach((a) => {
if (!a.read_at) {
a.read_at = new Date().toISOString()
}
})
await announcementStore.markAllAsRead()
appStore.showSuccess(t('announcements.allMarkedAsRead'))
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
} finally {
loading.value = false
}
}
@@ -438,22 +405,19 @@ function handleEscape(e: KeyboardEvent) {
onMounted(() => {
document.addEventListener('keydown', handleEscape)
loadAnnouncements()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleEscape)
// Restore body overflow in case component is unmounted while modals are open
document.body.style.overflow = ''
})
watch([isModalOpen, detailModalOpen], ([modal, detail]) => {
if (modal || detail) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
watch(
[isModalOpen, detailModalOpen, () => announcementStore.currentPopup],
([modal, detail, popup]) => {
document.body.style.overflow = (modal || detail || popup) ? 'hidden' : ''
}
})
)
</script>
<style scoped>

View File

@@ -0,0 +1,165 @@
<template>
<Teleport to="body">
<Transition name="popup-fade">
<div
v-if="announcementStore.currentPopup"
class="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
>
<div
class="w-full max-w-[680px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
@click.stop
>
<!-- Header with warm gradient -->
<div class="relative overflow-hidden border-b border-amber-100/80 bg-gradient-to-br from-amber-50/80 via-orange-50/50 to-yellow-50/30 px-8 py-6 dark:border-dark-700/50 dark:from-amber-900/20 dark:via-orange-900/10 dark:to-yellow-900/5">
<!-- Decorative background -->
<div class="absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-orange-100/30 to-transparent dark:from-orange-900/20"></div>
<div class="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-amber-400/20 to-orange-500/20 blur-3xl"></div>
<div class="absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-yellow-400/20 to-amber-500/20 blur-2xl"></div>
<div class="relative z-10">
<!-- Icon and badge -->
<div class="mb-3 flex items-center gap-2">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-600 text-white shadow-lg shadow-amber-500/30">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<span class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-amber-500 to-orange-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-amber-500/30">
<span class="relative flex h-2 w-2">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-white"></span>
</span>
{{ t('announcements.unread') }}
</span>
</div>
<!-- Title -->
<h2 class="mb-2 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
{{ announcementStore.currentPopup.title }}
</h2>
<!-- Time -->
<div class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<time>{{ formatRelativeWithDateTime(announcementStore.currentPopup.created_at) }}</time>
</div>
</div>
</div>
<!-- Body -->
<div class="max-h-[50vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800">
<div class="relative">
<div class="absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-amber-500 via-orange-500 to-yellow-500"></div>
<div class="pl-6">
<div
class="markdown-body prose prose-sm max-w-none dark:prose-invert"
v-html="renderedContent"
></div>
</div>
</div>
</div>
<!-- Footer -->
<div class="border-t border-gray-100 bg-gray-50/50 px-8 py-5 dark:border-dark-700 dark:bg-dark-900/30">
<div class="flex items-center justify-end">
<button
@click="handleDismiss"
class="rounded-xl bg-gradient-to-r from-amber-500 to-orange-600 px-6 py-2.5 text-sm font-medium text-white shadow-lg shadow-amber-500/30 transition-all hover:shadow-xl hover:scale-105"
>
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{{ t('announcements.markRead') }}
</span>
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useAnnouncementStore } from '@/stores/announcements'
import { formatRelativeWithDateTime } from '@/utils/format'
const { t } = useI18n()
const announcementStore = useAnnouncementStore()
marked.setOptions({
breaks: true,
gfm: true,
})
const renderedContent = computed(() => {
const content = announcementStore.currentPopup?.content
if (!content) return ''
const html = marked.parse(content) as string
return DOMPurify.sanitize(html)
})
function handleDismiss() {
announcementStore.dismissPopup()
}
// Manage body overflow — only set, never unset (bell component handles restore)
watch(
() => announcementStore.currentPopup,
(popup) => {
if (popup) {
document.body.style.overflow = 'hidden'
}
}
)
</script>
<style scoped>
.popup-fade-enter-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.popup-fade-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
}
.popup-fade-enter-from,
.popup-fade-leave-to {
opacity: 0;
}
.popup-fade-enter-from > div {
transform: scale(0.94) translateY(-12px);
opacity: 0;
}
.popup-fade-leave-to > div {
transform: scale(0.96) translateY(-8px);
opacity: 0;
}
/* Scrollbar Styling */
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
border-radius: 4px;
}
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #4b5563, #374151);
}
</style>

View File

@@ -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(

View File

@@ -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'

View File

@@ -22,6 +22,7 @@
/>
<GroupBadge
:name="group.name"
:platform="group.platform"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
class="min-w-0 flex-1"

View File

@@ -0,0 +1,146 @@
<template>
<div class="flex items-start gap-4">
<!-- Preview Box -->
<div class="flex-shrink-0">
<div
class="flex items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
:class="[previewSizeClass, { 'border-solid': !!modelValue }]"
>
<!-- SVG mode: render inline -->
<span
v-if="mode === 'svg' && modelValue"
class="text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
:class="innerSizeClass"
v-html="sanitizedValue"
></span>
<!-- Image mode: show as img -->
<img
v-else-if="mode === 'image' && modelValue"
:src="modelValue"
alt=""
class="h-full w-full object-contain"
/>
<!-- Empty placeholder -->
<svg
v-else
class="text-gray-400 dark:text-dark-500"
:class="placeholderSizeClass"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<!-- Controls -->
<div class="flex-1 space-y-2">
<div class="flex items-center gap-2">
<label class="btn btn-secondary btn-sm cursor-pointer">
<input
type="file"
:accept="acceptTypes"
class="hidden"
@change="handleUpload"
/>
<Icon name="upload" size="sm" class="mr-1.5" :stroke-width="2" />
{{ uploadLabel }}
</label>
<button
v-if="modelValue"
type="button"
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
@click="$emit('update:modelValue', '')"
>
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
{{ removeLabel }}
</button>
</div>
<p v-if="hint" class="text-xs text-gray-500 dark:text-gray-400">{{ hint }}</p>
<p v-if="error" class="text-xs text-red-500">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
const props = withDefaults(defineProps<{
modelValue: string
mode?: 'image' | 'svg'
size?: 'sm' | 'md'
uploadLabel?: string
removeLabel?: string
hint?: string
maxSize?: number // bytes
}>(), {
mode: 'image',
size: 'md',
uploadLabel: 'Upload',
removeLabel: 'Remove',
hint: '',
maxSize: 300 * 1024,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const error = ref('')
const acceptTypes = computed(() => props.mode === 'svg' ? '.svg' : 'image/*')
const sanitizedValue = computed(() =>
props.mode === 'svg' ? sanitizeSvg(props.modelValue ?? '') : ''
)
const previewSizeClass = computed(() => props.size === 'sm' ? 'h-14 w-14' : 'h-20 w-20')
const innerSizeClass = computed(() => props.size === 'sm' ? 'h-7 w-7' : 'h-12 w-12')
const placeholderSizeClass = computed(() => props.size === 'sm' ? 'h-5 w-5' : 'h-8 w-8')
function handleUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
error.value = ''
if (!file) return
if (props.maxSize && file.size > props.maxSize) {
error.value = `File too large (${(file.size / 1024).toFixed(1)} KB), max ${(props.maxSize / 1024).toFixed(0)} KB`
input.value = ''
return
}
const reader = new FileReader()
if (props.mode === 'svg') {
reader.onload = (e) => {
const text = e.target?.result as string
if (text) emit('update:modelValue', text.trim())
}
reader.readAsText(file)
} else {
if (!file.type.startsWith('image/')) {
error.value = 'Please select an image file'
input.value = ''
return
}
reader.onload = (e) => {
emit('update:modelValue', e.target?.result as string)
}
reader.readAsDataURL(file)
}
reader.onerror = () => {
error.value = 'Failed to read file'
}
input.value = ''
}
</script>

View File

@@ -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) {

View File

@@ -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="[

View File

@@ -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

View File

@@ -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>

View File

@@ -6,7 +6,7 @@
<div class="min-w-0 flex-1">
<p class="stat-label truncate">{{ title }}</p>
<div class="mt-1 flex items-baseline gap-2">
<p class="stat-value">{{ formattedValue }}</p>
<p class="stat-value" :title="String(formattedValue)">{{ formattedValue }}</p>
<span v-if="change !== undefined" :class="['stat-trend', trendClass]">
<Icon
v-if="changeType !== 'neutral'"

View File

@@ -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>

View File

@@ -58,6 +58,7 @@ const icons = {
arrowLeft: 'M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18',
arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18',
arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3',
arrowsUpDown: 'M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5',
chevronUp: 'M5 15l7-7 7 7',
externalLink: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',

View File

@@ -146,6 +146,7 @@ interface Props {
apiKey: string
baseUrl: string
platform: GroupPlatform | null
allowMessagesDispatch?: boolean
}
interface Emits {
@@ -265,11 +266,17 @@ const SparkleIcon = {
const clientTabs = computed((): TabConfig[] => {
if (!props.platform) return []
switch (props.platform) {
case 'openai':
return [
case 'openai': {
const tabs: TabConfig[] = [
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
{ id: 'codex-ws', label: t('keys.useKeyModal.cliTabs.codexCliWs'), icon: TerminalIcon },
]
if (props.allowMessagesDispatch) {
tabs.push({ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon })
}
tabs.push({ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon })
return tabs
}
case 'gemini':
return [
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
@@ -306,7 +313,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
const currentTabs = computed(() => {
if (!showShellTabs.value) return []
if (props.platform === 'openai') {
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws') {
return openaiTabs
}
return shellTabs
@@ -315,6 +322,9 @@ const currentTabs = computed(() => {
const platformDescription = computed(() => {
switch (props.platform) {
case 'openai':
if (activeClientTab.value === 'claude') {
return t('keys.useKeyModal.description')
}
return t('keys.useKeyModal.openai.description')
case 'gemini':
return t('keys.useKeyModal.gemini.description')
@@ -328,6 +338,9 @@ const platformDescription = computed(() => {
const platformNote = computed(() => {
switch (props.platform) {
case 'openai':
if (activeClientTab.value === 'claude') {
return t('keys.useKeyModal.note')
}
return activeTab.value === 'windows'
? t('keys.useKeyModal.openai.noteWindows')
: t('keys.useKeyModal.openai.note')
@@ -401,6 +414,12 @@ const currentFiles = computed((): FileConfig[] => {
switch (props.platform) {
case 'openai':
if (activeClientTab.value === 'claude') {
return generateAnthropicFiles(baseUrl, apiKey)
}
if (activeClientTab.value === 'codex-ws') {
return generateOpenAIWsFiles(baseUrl, apiKey)
}
return generateOpenAIFiles(baseUrl, apiKey)
case 'gemini':
return [generateGeminiCliContent(baseUrl, apiKey)]
@@ -439,7 +458,22 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
content = ''
}
return [{ path, content }]
const vscodeSettingsPath = activeTab.value === 'unix'
? '~/.claude/settings.json'
: '%userprofile%\\.claude\\settings.json'
const vscodeContent = `{
"env": {
"ANTHROPIC_BASE_URL": "${baseUrl}",
"ANTHROPIC_AUTH_TOKEN": "${apiKey}",
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
}
}`
return [
{ path, content },
{ path: vscodeSettingsPath, content: vscodeContent, hint: 'VSCode Claude Code' }
]
}
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
@@ -492,16 +526,18 @@ function generateOpenAIFiles(baseUrl: string, apiKey: string): FileConfig[] {
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
// config.toml content
const configContent = `model_provider = "sub2api"
model = "gpt-5.3-codex"
model_reasoning_effort = "high"
network_access = "enabled"
const configContent = `model_provider = "OpenAI"
model = "gpt-5.4"
review_model = "gpt-5.4"
model_reasoning_effort = "xhigh"
disable_response_storage = true
network_access = "enabled"
windows_wsl_setup_acknowledged = true
model_verbosity = "high"
model_context_window = 1000000
model_auto_compact_token_limit = 900000
[model_providers.sub2api]
name = "sub2api"
[model_providers.OpenAI]
name = "OpenAI"
base_url = "${baseUrl}"
wire_api = "responses"
requires_openai_auth = true`
@@ -524,6 +560,49 @@ requires_openai_auth = true`
]
}
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
// config.toml content with WebSocket v2
const configContent = `model_provider = "OpenAI"
model = "gpt-5.4"
review_model = "gpt-5.4"
model_reasoning_effort = "xhigh"
disable_response_storage = true
network_access = "enabled"
windows_wsl_setup_acknowledged = true
model_context_window = 1000000
model_auto_compact_token_limit = 900000
[model_providers.OpenAI]
name = "OpenAI"
base_url = "${baseUrl}"
wire_api = "responses"
supports_websockets = true
requires_openai_auth = true
[features]
responses_websockets_v2 = true`
// auth.json content
const authContent = `{
"OPENAI_API_KEY": "${apiKey}"
}`
return [
{
path: `${configDir}/config.toml`,
content: configContent,
hint: t('keys.useKeyModal.openai.configTomlHint')
},
{
path: `${configDir}/auth.json`,
content: authContent
}
]
}
function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: string, pathLabel?: string): FileConfig {
const provider: Record<string, any> = {
[platform]: {
@@ -534,8 +613,72 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
const openaiModels = {
'gpt-5.2-codex': {
name: 'GPT-5.2 Codex',
'gpt-5-codex': {
name: 'GPT-5 Codex',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.1-codex': {
name: 'GPT-5.1 Codex',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.1-codex-max': {
name: 'GPT-5.1 Codex Max',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.1-codex-mini': {
name: 'GPT-5.1 Codex Mini',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.2': {
name: 'GPT-5.2',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
@@ -545,30 +688,330 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
high: {},
xhigh: {}
}
},
'gpt-5.4': {
name: 'GPT-5.4',
limit: {
context: 1050000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.3-codex-spark': {
name: 'GPT-5.3 Codex Spark',
limit: {
context: 128000,
output: 32000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.3-codex': {
name: 'GPT-5.3 Codex',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.2-codex': {
name: 'GPT-5.2 Codex',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'codex-mini-latest': {
name: 'Codex Mini',
limit: {
context: 200000,
output: 100000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
}
}
const geminiModels = {
'gemini-2.0-flash': { name: 'Gemini 2.0 Flash' },
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
'gemini-2.5-pro': { name: 'Gemini 2.5 Pro' },
'gemini-3-flash-preview': { name: 'Gemini 3 Flash Preview' },
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' }
'gemini-2.0-flash': {
name: 'Gemini 2.0 Flash',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
}
},
'gemini-2.5-flash': {
name: 'Gemini 2.5 Flash',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
}
},
'gemini-2.5-pro': {
name: 'Gemini 2.5 Pro',
limit: {
context: 2097152,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-3-flash-preview': {
name: 'Gemini 3 Flash Preview',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
}
},
'gemini-3-pro-preview': {
name: 'Gemini 3 Pro Preview',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-3.1-pro-preview': {
name: 'Gemini 3.1 Pro Preview',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
}
}
const antigravityGeminiModels = {
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' },
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
'gemini-3-flash': { name: 'Gemini 3 Flash' },
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' }
'gemini-2.5-flash': {
name: 'Gemini 2.5 Flash',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'disable'
}
}
},
'gemini-2.5-flash-lite': {
name: 'Gemini 2.5 Flash Lite',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-2.5-flash-thinking': {
name: 'Gemini 2.5 Flash (Thinking)',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-3-flash': {
name: 'Gemini 3 Flash',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-3.1-pro-low': {
name: 'Gemini 3.1 Pro Low',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-3.1-pro-high': {
name: 'Gemini 3.1 Pro High',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'gemini-3.1-flash-image': {
name: 'Gemini 3.1 Flash Image',
limit: {
context: 1048576,
output: 65536
},
modalities: {
input: ['text', 'image'],
output: ['image']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
}
}
const claudeModels = {
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
'claude-sonnet-4-5-thinking': { name: 'Claude Sonnet 4.5 Thinking' },
'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5' }
'claude-opus-4-6-thinking': {
name: 'Claude 4.6 Opus (Thinking)',
limit: {
context: 200000,
output: 128000
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
},
'claude-sonnet-4-6': {
name: 'Claude 4.6 Sonnet',
limit: {
context: 200000,
output: 64000
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
budgetTokens: 24576,
type: 'enabled'
}
}
}
}
if (platform === 'gemini') {

View File

@@ -207,6 +207,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import { useAdminSettingsStore } from '@/stores/adminSettings'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
@@ -217,6 +218,7 @@ const route = useRoute()
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const adminSettingsStore = useAdminSettingsStore()
const onboardingStore = useOnboardingStore()
const user = computed(() => authStore.user)
@@ -250,6 +252,14 @@ const displayName = computed(() => {
})
const pageTitle = computed(() => {
// For custom pages, use the menu item's label instead of generic "自定义页面"
if (route.name === 'CustomPage') {
const id = route.params.id as string
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
const menuItem = publicItems.find((item) => item.id === id)
?? (authStore.isAdmin ? adminSettingsStore.customMenuItems.find((item) => item.id === id) : undefined)
if (menuItem?.label) return menuItem.label
}
const titleKey = route.meta.titleKey as string
if (titleKey) {
return t(titleKey)

View File

@@ -10,7 +10,7 @@
<div class="sidebar-header">
<!-- Custom Logo or Default Logo -->
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
<img v-if="settingsLoaded" :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<transition name="fade">
<div v-if="!sidebarCollapsed" class="flex flex-col">
@@ -47,7 +47,8 @@
"
@click="handleMenuItemClick(item.path)"
>
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
@@ -71,7 +72,8 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
@@ -92,7 +94,8 @@
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
@@ -149,6 +152,15 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'
import { sanitizeSvg } from '@/utils/sanitize'
interface NavItem {
path: string
label: string
icon: unknown
iconSvg?: string
hideInSimpleMode?: boolean
}
const { t } = useI18n()
@@ -167,6 +179,7 @@ const isDark = ref(document.documentElement.classList.contains('dark'))
const siteName = computed(() => appStore.siteName)
const siteLogo = computed(() => appStore.siteLogo)
const siteVersion = computed(() => appStore.siteVersion)
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
// SVG Icon Components
const DashboardIcon = {
@@ -289,6 +302,31 @@ const CreditCardIcon = {
)
}
const RechargeSubscriptionIcon = {
render: () =>
h(
'svg',
{ fill: 'currentColor', viewBox: '0 0 1024 1024' },
[
h('path', {
d: 'M512 992C247.3 992 32 776.7 32 512S247.3 32 512 32s480 215.3 480 480c0 84.4-22.2 167.4-64.2 240-8.9 15.3-28.4 20.6-43.7 11.7-15.3-8.8-20.5-28.4-11.7-43.7 36.4-62.9 55.6-134.8 55.6-208 0-229.4-186.6-416-416-416S96 282.6 96 512s186.6 416 416 416c17.7 0 32 14.3 32 32s-14.3 32-32 32z'
}),
h('path', {
d: 'M640 512H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h256c17.7 0 32 14.3 32 32s-14.3 32-32 32zM640 640H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h256c17.7 0 32 14.3 32 32s-14.3 32-32 32z'
}),
h('path', {
d: 'M512 480c-8.2 0-16.4-3.1-22.6-9.4l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3-6.3 6.3-14.5 9.4-22.7 9.4z'
}),
h('path', {
d: 'M512 480c-8.2 0-16.4-3.1-22.6-9.4-12.5-12.5-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128c-6.3 6.3-14.5 9.4-22.7 9.4z'
}),
h('path', {
d: 'M512 736c-17.7 0-32-14.3-32-32V448c0-17.7 14.3-32 32-32s32 14.3 32 32v256c0 17.7-14.3 32-32 32zM896 992H512c-17.7 0-32-14.3-32-32s14.3-32 32-32h306.8l-73.4-73.4c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l128 128c9.2 9.2 11.9 22.9 6.9 34.9S908.9 992 896 992z'
})
]
)
}
const GlobeIcon = {
render: () =>
h(
@@ -319,6 +357,36 @@ const ServerIcon = {
)
}
const DatabaseIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M3.75 5.25C3.75 4.007 7.443 3 12 3s8.25 1.007 8.25 2.25S16.557 7.5 12 7.5 3.75 6.493 3.75 5.25z'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M3.75 5.25v4.5C3.75 10.993 7.443 12 12 12s8.25-1.007 8.25-2.25v-4.5'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M3.75 9.75v4.5c0 1.243 3.693 2.25 8.25 2.25s8.25-1.007 8.25-2.25v-4.5'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M3.75 14.25v4.5C3.75 19.993 7.443 21 12 21s8.25-1.007 8.25-2.25v-4.5'
})
]
)
}
const BellIcon = {
render: () =>
h(
@@ -414,6 +482,21 @@ const ChevronDoubleLeftIcon = {
)
}
const SoraIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z'
})
]
)
}
const ChevronDoubleRightIcon = {
render: () =>
h(
@@ -430,53 +513,85 @@ const ChevronDoubleRightIcon = {
}
// User navigation items (for regular users)
const userNavItems = computed(() => {
const items = [
const userNavItems = computed((): NavItem[] => {
const items: NavItem[] = [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.sora_client_enabled
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
: []),
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
icon: RechargeSubscriptionIcon,
hideInSimpleMode: true
}
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,
label: item.label,
icon: null,
iconSvg: item.icon_svg,
})),
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => {
const items = [
const personalNavItems = computed((): NavItem[] => {
const items: NavItem[] = [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.sora_client_enabled
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
: []),
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
icon: RechargeSubscriptionIcon,
hideInSimpleMode: true
}
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,
label: item.label,
icon: null,
iconSvg: item.icon_svg,
})),
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Custom menu items filtered by visibility
const customMenuItemsForUser = computed(() => {
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
return items
.filter((item) => item.visibility === 'user')
.sort((a, b) => a.sort_order - b.sort_order)
})
const customMenuItemsForAdmin = computed(() => {
return adminSettingsStore.customMenuItems
.filter((item) => item.visibility === 'admin')
.sort((a, b) => a.sort_order - b.sort_order)
})
// Admin navigation items
const adminNavItems = computed(() => {
const baseItems = [
const adminNavItems = computed((): NavItem[] => {
const baseItems: NavItem[] = [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
...(adminSettingsStore.opsMonitoringEnabled
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
@@ -489,18 +604,28 @@ const adminNavItems = computed(() => {
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
]
// 简单模式下,在系统设置前插入 API密钥
if (authStore.isSimpleMode) {
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings
for (const cm of customMenuItemsForAdmin.value) {
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
}
return filtered
}
baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings
for (const cm of customMenuItemsForAdmin.value) {
baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
}
return baseItems
})
@@ -580,4 +705,12 @@ onMounted(() => {
.fade-leave-to {
opacity: 0;
}
/* Custom SVG icon in sidebar: inherit color, constrain size */
.sidebar-svg-icon :deep(svg) {
width: 1.25rem;
height: 1.25rem;
stroke: currentColor;
fill: none;
}
</style>

View File

@@ -29,17 +29,19 @@
<!-- Logo/Brand -->
<div class="mb-8 text-center">
<!-- Custom Logo or Default Logo -->
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<h1 class="text-gradient mb-2 text-3xl font-bold">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
</p>
<template v-if="settingsLoaded">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<h1 class="text-gradient mb-2 text-3xl font-bold">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
</p>
</template>
</div>
<!-- Card Container -->
@@ -61,25 +63,21 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getPublicSettings } from '@/api/auth'
import { computed, onMounted } from 'vue'
import { useAppStore } from '@/stores'
import { sanitizeUrl } from '@/utils/url'
const siteName = ref('StarFireAPI')
const siteLogo = ref('')
const siteSubtitle = ref('Subscription to API Conversion Platform')
const appStore = useAppStore()
const siteName = computed(() => appStore.siteName || 'StarFireAPI')
const siteLogo = computed(() => sanitizeUrl(appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true }))
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'Subscription to API Conversion Platform')
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
const currentYear = computed(() => new Date().getFullYear())
onMounted(async () => {
try {
const settings = await getPublicSettings()
siteName.value = settings.site_name || 'StarFireAPI'
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
} catch (error) {
console.error('Failed to load public settings:', error)
}
onMounted(() => {
appStore.fetchPublicSettings()
})
</script>

View File

@@ -0,0 +1,217 @@
<template>
<Teleport to="body">
<Transition name="sora-modal">
<div v-if="visible && generation" class="sora-download-overlay" @click.self="emit('close')">
<div class="sora-download-backdrop" />
<div class="sora-download-modal" @click.stop>
<div class="sora-download-modal-icon">📥</div>
<h3 class="sora-download-modal-title">{{ t('sora.downloadTitle') }}</h3>
<p class="sora-download-modal-desc">{{ t('sora.downloadExpirationWarning') }}</p>
<!-- 倒计时 -->
<div v-if="remainingText" class="sora-download-countdown">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span :class="{ expired: isExpired }">
{{ isExpired ? t('sora.upstreamExpired') : t('sora.upstreamCountdown', { time: remainingText }) }}
</span>
</div>
<div class="sora-download-modal-actions">
<a
v-if="generation.media_url"
:href="generation.media_url"
target="_blank"
download
class="sora-download-btn primary"
>
{{ t('sora.downloadNow') }}
</a>
<button class="sora-download-btn ghost" @click="emit('close')">
{{ t('sora.closePreview') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
const EXPIRATION_MINUTES = 15
const props = defineProps<{
visible: boolean
generation: SoraGeneration | null
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | null = null
const expiresAt = computed(() => {
if (!props.generation?.completed_at) return null
return new Date(props.generation.completed_at).getTime() + EXPIRATION_MINUTES * 60 * 1000
})
const isExpired = computed(() => {
if (!expiresAt.value) return false
return now.value >= expiresAt.value
})
const remainingText = computed(() => {
if (!expiresAt.value) return ''
const diff = expiresAt.value - now.value
if (diff <= 0) return ''
const minutes = Math.floor(diff / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
return `${minutes}:${String(seconds).padStart(2, '0')}`
})
watch(
() => props.visible,
(v) => {
if (v) {
now.value = Date.now()
timer = setInterval(() => { now.value = Date.now() }, 1000)
} else if (timer) {
clearInterval(timer)
timer = null
}
},
{ immediate: true }
)
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.sora-download-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.sora-download-backdrop {
position: absolute;
inset: 0;
background: var(--sora-modal-backdrop, rgba(0, 0, 0, 0.4));
backdrop-filter: blur(4px);
}
.sora-download-modal {
position: relative;
z-index: 10;
background: var(--sora-bg-secondary, #FFF);
border: 1px solid var(--sora-border-color, #E5E7EB);
border-radius: 20px;
padding: 32px;
max-width: 420px;
width: 90%;
text-align: center;
animation: sora-modal-in 0.3s ease;
}
@keyframes sora-modal-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.sora-download-modal-icon {
font-size: 48px;
margin-bottom: 16px;
}
.sora-download-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--sora-text-primary, #111827);
margin-bottom: 8px;
}
.sora-download-modal-desc {
font-size: 14px;
color: var(--sora-text-secondary, #6B7280);
margin-bottom: 20px;
line-height: 1.6;
}
.sora-download-countdown {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 14px;
color: var(--sora-text-secondary, #6B7280);
margin-bottom: 24px;
}
.sora-download-countdown svg {
color: var(--sora-text-tertiary, #9CA3AF);
}
.sora-download-countdown .expired {
color: #EF4444;
}
.sora-download-modal-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.sora-download-btn {
padding: 10px 24px;
border-radius: 9999px;
font-size: 14px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sora-download-btn.primary {
background: var(--sora-accent-gradient);
color: white;
}
.sora-download-btn.primary:hover {
box-shadow: var(--sora-shadow-glow);
}
.sora-download-btn.ghost {
background: var(--sora-bg-tertiary, #F3F4F6);
color: var(--sora-text-secondary, #6B7280);
}
.sora-download-btn.ghost:hover {
background: var(--sora-bg-hover, #E5E7EB);
color: var(--sora-text-primary, #111827);
}
/* 过渡 */
.sora-modal-enter-active,
.sora-modal-leave-active {
transition: opacity 0.2s ease;
}
.sora-modal-enter-from,
.sora-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,430 @@
<template>
<div class="sora-generate-page">
<div class="sora-task-area">
<!-- 欢迎区域无任务时显示 -->
<div v-if="activeGenerations.length === 0" class="sora-welcome-section">
<h1 class="sora-welcome-title">{{ t('sora.welcomeTitle') }}</h1>
<p class="sora-welcome-subtitle">{{ t('sora.welcomeSubtitle') }}</p>
</div>
<!-- 示例提示词无任务时显示 -->
<div v-if="activeGenerations.length === 0" class="sora-example-prompts">
<button
v-for="(example, idx) in examplePrompts"
:key="idx"
class="sora-example-prompt"
@click="fillPrompt(example)"
>
{{ example }}
</button>
</div>
<!-- 任务卡片列表 -->
<div v-if="activeGenerations.length > 0" class="sora-task-cards">
<SoraProgressCard
v-for="gen in activeGenerations"
:key="gen.id"
:generation="gen"
@cancel="handleCancel"
@delete="handleDelete"
@save="handleSave"
@retry="handleRetry"
/>
</div>
<!-- 无存储提示 Toast -->
<div v-if="showNoStorageToast" class="sora-no-storage-toast">
<span></span>
<span>{{ t('sora.noStorageToastMessage') }}</span>
</div>
</div>
<!-- 底部创作栏 -->
<SoraPromptBar
ref="promptBarRef"
:generating="generating"
:active-task-count="activeTaskCount"
:max-concurrent-tasks="3"
@generate="handleGenerate"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAPI, { type SoraGeneration, type GenerateRequest } from '@/api/sora'
import SoraProgressCard from './SoraProgressCard.vue'
import SoraPromptBar from './SoraPromptBar.vue'
const { t } = useI18n()
const emit = defineEmits<{
'task-count-change': [counts: { active: number; generating: boolean }]
}>()
const activeGenerations = ref<SoraGeneration[]>([])
const generating = ref(false)
const showNoStorageToast = ref(false)
let pollTimers: Record<number, ReturnType<typeof setTimeout>> = {}
const promptBarRef = ref<InstanceType<typeof SoraPromptBar> | null>(null)
// 示例提示词
const examplePrompts = [
'一只金色的柴犬在东京涩谷街头散步镜头跟随电影感画面4K 高清',
'无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
'赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
'水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
]
// 活跃任务统计
const activeTaskCount = computed(() =>
activeGenerations.value.filter(g => g.status === 'pending' || g.status === 'generating').length
)
const hasGeneratingTask = computed(() =>
activeGenerations.value.some(g => g.status === 'generating')
)
// 通知父组件任务数变化
watch([activeTaskCount, hasGeneratingTask], () => {
emit('task-count-change', {
active: activeTaskCount.value,
generating: hasGeneratingTask.value
})
}, { immediate: true })
// ==================== 浏览器通知 ====================
function requestNotificationPermission() {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission()
}
}
function sendNotification(title: string, body: string) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
}
}
const originalTitle = document.title
let titleBlinkTimer: ReturnType<typeof setInterval> | null = null
function startTitleBlink(message: string) {
stopTitleBlink()
let show = true
titleBlinkTimer = setInterval(() => {
document.title = show ? message : originalTitle
show = !show
}, 1000)
const onFocus = () => {
stopTitleBlink()
window.removeEventListener('focus', onFocus)
}
window.addEventListener('focus', onFocus)
}
function stopTitleBlink() {
if (titleBlinkTimer) {
clearInterval(titleBlinkTimer)
titleBlinkTimer = null
}
document.title = originalTitle
}
function checkStatusTransition(oldGen: SoraGeneration, newGen: SoraGeneration) {
const wasActive = oldGen.status === 'pending' || oldGen.status === 'generating'
if (!wasActive) return
if (newGen.status === 'completed') {
const title = t('sora.notificationCompleted')
const body = t('sora.notificationCompletedBody', { model: newGen.model })
sendNotification(title, body)
if (document.hidden) startTitleBlink(title)
} else if (newGen.status === 'failed') {
const title = t('sora.notificationFailed')
const body = t('sora.notificationFailedBody', { model: newGen.model })
sendNotification(title, body)
if (document.hidden) startTitleBlink(title)
}
}
// ==================== beforeunload ====================
const hasUpstreamRecords = computed(() =>
activeGenerations.value.some(g => g.status === 'completed' && g.storage_type === 'upstream')
)
function beforeUnloadHandler(e: BeforeUnloadEvent) {
if (hasUpstreamRecords.value) {
e.preventDefault()
e.returnValue = t('sora.beforeUnloadWarning')
return e.returnValue
}
}
// ==================== 轮询 ====================
function getPollingIntervalByRuntime(createdAt: string): number {
const createdAtMs = new Date(createdAt).getTime()
if (Number.isNaN(createdAtMs)) return 3000
const elapsedMs = Date.now() - createdAtMs
if (elapsedMs < 2 * 60 * 1000) return 3000
if (elapsedMs < 10 * 60 * 1000) return 10000
return 30000
}
function schedulePolling(id: number) {
const current = activeGenerations.value.find(g => g.id === id)
const interval = current ? getPollingIntervalByRuntime(current.created_at) : 3000
if (pollTimers[id]) clearTimeout(pollTimers[id])
pollTimers[id] = setTimeout(() => { void pollGeneration(id) }, interval)
}
async function pollGeneration(id: number) {
try {
const gen = await soraAPI.getGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) {
checkStatusTransition(activeGenerations.value[idx], gen)
activeGenerations.value[idx] = gen
}
if (gen.status === 'pending' || gen.status === 'generating') {
schedulePolling(id)
} else {
delete pollTimers[id]
}
} catch {
delete pollTimers[id]
}
}
async function loadActiveGenerations() {
try {
const res = await soraAPI.listGenerations({
status: 'pending,generating,completed,failed,cancelled',
page_size: 50
})
const generations = Array.isArray(res.data) ? res.data : []
activeGenerations.value = generations
for (const gen of generations) {
if ((gen.status === 'pending' || gen.status === 'generating') && !pollTimers[gen.id]) {
schedulePolling(gen.id)
}
}
} catch (e) {
console.error('Failed to load generations:', e)
}
}
// ==================== 操作 ====================
async function handleGenerate(req: GenerateRequest) {
generating.value = true
try {
const res = await soraAPI.generate(req)
const gen = await soraAPI.getGeneration(res.generation_id)
activeGenerations.value.unshift(gen)
schedulePolling(gen.id)
} catch (e: any) {
console.error('Generate failed:', e)
alert(e?.response?.data?.message || e?.message || 'Generation failed')
} finally {
generating.value = false
}
}
async function handleCancel(id: number) {
try {
await soraAPI.cancelGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) activeGenerations.value[idx].status = 'cancelled'
} catch (e) {
console.error('Cancel failed:', e)
}
}
async function handleDelete(id: number) {
try {
await soraAPI.deleteGeneration(id)
activeGenerations.value = activeGenerations.value.filter(g => g.id !== id)
} catch (e) {
console.error('Delete failed:', e)
}
}
async function handleSave(id: number) {
try {
await soraAPI.saveToStorage(id)
const gen = await soraAPI.getGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) activeGenerations.value[idx] = gen
} catch (e) {
console.error('Save failed:', e)
}
}
function handleRetry(gen: SoraGeneration) {
handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
}
function fillPrompt(text: string) {
promptBarRef.value?.fillPrompt(text)
}
// ==================== 检查存储状态 ====================
async function checkStorageStatus() {
try {
const status = await soraAPI.getStorageStatus()
if (!status.s3_enabled || !status.s3_healthy) {
showNoStorageToast.value = true
setTimeout(() => { showNoStorageToast.value = false }, 8000)
}
} catch {
// 忽略
}
}
onMounted(() => {
loadActiveGenerations()
requestNotificationPermission()
checkStorageStatus()
window.addEventListener('beforeunload', beforeUnloadHandler)
})
onUnmounted(() => {
Object.values(pollTimers).forEach(clearTimeout)
pollTimers = {}
stopTitleBlink()
window.removeEventListener('beforeunload', beforeUnloadHandler)
})
</script>
<style scoped>
.sora-generate-page {
padding-bottom: 200px;
min-height: calc(100vh - 56px);
display: flex;
flex-direction: column;
}
/* 任务区域 */
.sora-task-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
gap: 24px;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* 欢迎区域 */
.sora-welcome-section {
text-align: center;
padding: 60px 0 40px;
}
.sora-welcome-title {
font-size: 36px;
font-weight: 700;
letter-spacing: -0.03em;
margin-bottom: 12px;
background: linear-gradient(135deg, var(--sora-text-primary) 0%, var(--sora-text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sora-welcome-subtitle {
font-size: 16px;
color: var(--sora-text-secondary, #A0A0A0);
max-width: 480px;
margin: 0 auto;
line-height: 1.6;
}
/* 示例提示词 */
.sora-example-prompts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
max-width: 640px;
}
.sora-example-prompt {
padding: 16px 20px;
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-md, 12px);
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
cursor: pointer;
transition: all 150ms ease;
text-align: left;
line-height: 1.5;
font-family: inherit;
}
.sora-example-prompt:hover {
background: var(--sora-bg-tertiary, #242424);
border-color: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
transform: translateY(-1px);
}
/* 任务卡片列表 */
.sora-task-cards {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
/* 无存储 Toast */
.sora-no-storage-toast {
position: fixed;
top: 80px;
right: 24px;
background: var(--sora-bg-elevated, #2A2A2A);
border: 1px solid var(--sora-warning, #F59E0B);
border-radius: var(--sora-radius-md, 12px);
padding: 14px 20px;
font-size: 13px;
color: var(--sora-warning, #F59E0B);
z-index: 50;
box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
animation: sora-slide-in-right 0.3s ease;
max-width: 340px;
display: flex;
align-items: center;
gap: 10px;
}
@keyframes sora-slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 响应式 */
@media (max-width: 900px) {
.sora-example-prompts {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.sora-welcome-title {
font-size: 28px;
}
.sora-task-area {
padding: 24px 16px;
}
}
</style>

View File

@@ -0,0 +1,576 @@
<template>
<div class="sora-gallery-page">
<!-- 筛选栏 -->
<div class="sora-gallery-filter-bar">
<div class="sora-gallery-filters">
<button
v-for="f in filters"
:key="f.value"
:class="['sora-gallery-filter', activeFilter === f.value && 'active']"
@click="activeFilter = f.value"
>
{{ f.label }}
</button>
</div>
<span class="sora-gallery-count">
{{ t('sora.galleryCount', { count: filteredItems.length }) }}
</span>
</div>
<!-- 作品网格 -->
<div v-if="filteredItems.length > 0" class="sora-gallery-grid">
<div
v-for="item in filteredItems"
:key="item.id"
class="sora-gallery-card"
@click="openPreview(item)"
>
<div class="sora-gallery-card-thumb">
<!-- 媒体 -->
<video
v-if="item.media_type === 'video' && item.media_url"
:src="item.media_url"
class="sora-gallery-card-image"
muted
loop
@mouseenter="($event.target as HTMLVideoElement).play()"
@mouseleave="($event.target as HTMLVideoElement).pause()"
/>
<img
v-else-if="item.media_url"
:src="item.media_url"
class="sora-gallery-card-image"
alt=""
/>
<div v-else class="sora-gallery-card-image sora-gallery-card-placeholder" :class="getGradientClass(item.id)">
{{ item.media_type === 'video' ? '🎬' : '🎨' }}
</div>
<!-- 类型角标 -->
<span
class="sora-gallery-card-badge"
:class="item.media_type === 'video' ? 'video' : 'image'"
>
{{ item.media_type === 'video' ? 'VIDEO' : 'IMAGE' }}
</span>
<!-- Hover 操作层 -->
<div class="sora-gallery-card-overlay">
<button
v-if="item.media_url"
class="sora-gallery-card-action"
title="下载"
@click.stop="handleDownload(item)"
>
📥
</button>
<button
class="sora-gallery-card-action"
title="删除"
@click.stop="handleDelete(item.id)"
>
🗑
</button>
</div>
<!-- 视频播放指示 -->
<div v-if="item.media_type === 'video'" class="sora-gallery-card-play"></div>
<!-- 视频时长 -->
<span v-if="item.media_type === 'video'" class="sora-gallery-card-duration">
{{ formatDuration(item) }}
</span>
</div>
<!-- 卡片底部信息 -->
<div class="sora-gallery-card-info">
<div class="sora-gallery-card-model">{{ item.model }}</div>
<div class="sora-gallery-card-time">{{ formatTime(item.created_at) }}</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!loading" class="sora-gallery-empty">
<div class="sora-gallery-empty-icon">🎬</div>
<h2 class="sora-gallery-empty-title">{{ t('sora.galleryEmptyTitle') }}</h2>
<p class="sora-gallery-empty-desc">{{ t('sora.galleryEmptyDesc') }}</p>
<button class="sora-gallery-empty-btn" @click="emit('switchToGenerate')">
{{ t('sora.startCreating') }}
</button>
</div>
<!-- 加载更多 -->
<div v-if="hasMore && filteredItems.length > 0" class="sora-gallery-load-more">
<button
class="sora-gallery-load-more-btn"
:disabled="loading"
@click="loadMore"
>
{{ loading ? t('sora.loading') : t('sora.loadMore') }}
</button>
</div>
<!-- 预览弹窗 -->
<SoraMediaPreview
:visible="previewVisible"
:generation="previewItem"
@close="previewVisible = false"
@save="handleSaveFromPreview"
@download="handleDownloadUrl"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAPI, { type SoraGeneration } from '@/api/sora'
import SoraMediaPreview from './SoraMediaPreview.vue'
const emit = defineEmits<{
'switchToGenerate': []
}>()
const { t } = useI18n()
const items = ref<SoraGeneration[]>([])
const loading = ref(false)
const page = ref(1)
const hasMore = ref(true)
const activeFilter = ref('all')
const previewVisible = ref(false)
const previewItem = ref<SoraGeneration | null>(null)
const filters = computed(() => [
{ value: 'all', label: t('sora.filterAll') },
{ value: 'video', label: t('sora.filterVideo') },
{ value: 'image', label: t('sora.filterImage') }
])
const filteredItems = computed(() => {
if (activeFilter.value === 'all') return items.value
return items.value.filter(i => i.media_type === activeFilter.value)
})
const gradientClasses = [
'gradient-bg-1', 'gradient-bg-2', 'gradient-bg-3', 'gradient-bg-4',
'gradient-bg-5', 'gradient-bg-6', 'gradient-bg-7', 'gradient-bg-8'
]
function getGradientClass(id: number): string {
return gradientClasses[id % gradientClasses.length]
}
function formatTime(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return t('sora.justNow')
if (diff < 3600000) return t('sora.minutesAgo', { n: Math.floor(diff / 60000) })
if (diff < 86400000) return t('sora.hoursAgo', { n: Math.floor(diff / 3600000) })
if (diff < 2 * 86400000) return t('sora.yesterday')
return d.toLocaleDateString()
}
function formatDuration(item: SoraGeneration): string {
// 从模型名提取时长,如 sora2-landscape-10s -> 0:10
const match = item.model.match(/(\d+)s$/)
if (match) {
const sec = parseInt(match[1])
return `0:${sec.toString().padStart(2, '0')}`
}
return '0:10'
}
async function loadItems(pageNum: number) {
loading.value = true
try {
const res = await soraAPI.listGenerations({
status: 'completed',
storage_type: 's3,local',
page: pageNum,
page_size: 20
})
const rows = Array.isArray(res.data) ? res.data : []
if (pageNum === 1) {
items.value = rows
} else {
items.value.push(...rows)
}
hasMore.value = items.value.length < res.total
} catch (e) {
console.error('Failed to load library:', e)
} finally {
loading.value = false
}
}
function loadMore() {
page.value++
loadItems(page.value)
}
function openPreview(item: SoraGeneration) {
previewItem.value = item
previewVisible.value = true
}
async function handleDelete(id: number) {
if (!confirm(t('sora.confirmDelete'))) return
try {
await soraAPI.deleteGeneration(id)
items.value = items.value.filter(i => i.id !== id)
} catch (e) {
console.error('Delete failed:', e)
}
}
function handleDownload(item: SoraGeneration) {
if (item.media_url) {
window.open(item.media_url, '_blank')
}
}
function handleDownloadUrl(url: string) {
window.open(url, '_blank')
}
async function handleSaveFromPreview(id: number) {
try {
await soraAPI.saveToStorage(id)
const gen = await soraAPI.getGeneration(id)
const idx = items.value.findIndex(i => i.id === id)
if (idx >= 0) items.value[idx] = gen
} catch (e) {
console.error('Save failed:', e)
}
}
onMounted(() => loadItems(1))
</script>
<style scoped>
.sora-gallery-page {
padding: 24px;
padding-bottom: 40px;
}
/* 筛选栏 */
.sora-gallery-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.sora-gallery-filters {
display: flex;
gap: 4px;
background: var(--sora-bg-secondary, #1A1A1A);
border-radius: var(--sora-radius-full, 9999px);
padding: 3px;
}
.sora-gallery-filter {
padding: 6px 18px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
color: var(--sora-text-secondary, #A0A0A0);
background: none;
border: none;
cursor: pointer;
transition: all 150ms ease;
user-select: none;
}
.sora-gallery-filter:hover {
color: var(--sora-text-primary, #FFF);
}
.sora-gallery-filter.active {
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
}
.sora-gallery-count {
font-size: 13px;
color: var(--sora-text-tertiary, #666);
}
/* 网格 */
.sora-gallery-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
/* 卡片 */
.sora-gallery-card {
position: relative;
border-radius: var(--sora-radius-md, 12px);
overflow: hidden;
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
cursor: pointer;
transition: all 250ms ease;
}
.sora-gallery-card:hover {
border-color: var(--sora-bg-hover, #333);
transform: translateY(-2px);
box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
}
.sora-gallery-card-thumb {
position: relative;
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
}
.sora-gallery-card-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 400ms ease;
}
.sora-gallery-card:hover .sora-gallery-card-image {
transform: scale(1.05);
}
.sora-gallery-card-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
/* 渐变背景 */
.gradient-bg-1 { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.gradient-bg-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
.gradient-bg-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
.gradient-bg-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
.gradient-bg-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
.gradient-bg-6 { background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); }
.gradient-bg-7 { background: linear-gradient(135deg, #fccb90 0%, #d57eeb 100%); }
.gradient-bg-8 { background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%); }
/* 类型角标 */
.sora-gallery-card-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 8px;
border-radius: var(--sora-radius-sm, 8px);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
backdrop-filter: blur(8px);
}
.sora-gallery-card-badge.video {
background: rgba(20, 184, 166, 0.8);
color: white;
}
.sora-gallery-card-badge.image {
background: rgba(16, 185, 129, 0.8);
color: white;
}
/* Hover 操作层 */
.sora-gallery-card-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
opacity: 0;
transition: opacity 150ms ease;
}
.sora-gallery-card:hover .sora-gallery-card-overlay {
opacity: 1;
}
.sora-gallery-card-action {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-gallery-card-action:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.1);
}
/* 播放指示 */
.sora-gallery-card-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
opacity: 0;
transition: all 150ms ease;
pointer-events: none;
}
.sora-gallery-card:hover .sora-gallery-card-play {
opacity: 1;
}
/* 视频时长 */
.sora-gallery-card-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.7);
font-size: 11px;
font-family: "SF Mono", "Fira Code", monospace;
color: white;
}
/* 卡片信息 */
.sora-gallery-card-info {
padding: 12px;
}
.sora-gallery-card-model {
font-size: 11px;
font-family: "SF Mono", "Fira Code", monospace;
color: var(--sora-text-tertiary, #666);
margin-bottom: 4px;
}
.sora-gallery-card-time {
font-size: 12px;
color: var(--sora-text-muted, #4A4A4A);
}
/* 空状态 */
.sora-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 40px;
text-align: center;
}
.sora-gallery-empty-icon {
font-size: 64px;
margin-bottom: 24px;
opacity: 0.3;
}
.sora-gallery-empty-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-gallery-empty-desc {
font-size: 14px;
color: var(--sora-text-tertiary, #666);
max-width: 360px;
line-height: 1.6;
}
.sora-gallery-empty-btn {
margin-top: 24px;
padding: 10px 28px;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: var(--sora-radius-full, 9999px);
font-size: 14px;
font-weight: 500;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-gallery-empty-btn:hover {
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
/* 加载更多 */
.sora-gallery-load-more {
display: flex;
justify-content: center;
margin-top: 24px;
}
.sora-gallery-load-more-btn {
padding: 10px 28px;
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
cursor: pointer;
transition: all 150ms ease;
}
.sora-gallery-load-more-btn:hover {
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
}
.sora-gallery-load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 响应式 */
@media (max-width: 1200px) {
.sora-gallery-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.sora-gallery-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.sora-gallery-page {
padding: 16px;
}
.sora-gallery-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<Teleport to="body">
<Transition name="sora-modal">
<div
v-if="visible && generation"
class="sora-preview-overlay"
@keydown.esc="emit('close')"
>
<!-- 背景遮罩 -->
<div class="sora-preview-backdrop" @click="emit('close')" />
<!-- 内容区 -->
<div class="sora-preview-modal">
<!-- 顶部栏 -->
<div class="sora-preview-header">
<h3 class="sora-preview-title">{{ t('sora.previewTitle') }}</h3>
<button class="sora-preview-close" @click="emit('close')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 媒体区 -->
<div class="sora-preview-media-area">
<video
v-if="generation.media_type === 'video'"
:src="generation.media_url"
class="sora-preview-media"
controls
autoplay
/>
<img
v-else
:src="generation.media_url"
class="sora-preview-media"
alt=""
/>
</div>
<!-- 详情 + 操作 -->
<div class="sora-preview-footer">
<!-- 模型 + 时间 -->
<div class="sora-preview-meta">
<span class="sora-preview-model-tag">{{ generation.model }}</span>
<span>{{ formatDateTime(generation.created_at) }}</span>
</div>
<!-- 提示词 -->
<p class="sora-preview-prompt">{{ generation.prompt }}</p>
<!-- 操作按钮 -->
<div class="sora-preview-actions">
<button
v-if="generation.storage_type === 'upstream'"
class="sora-preview-btn primary"
@click="emit('save', generation.id)"
>
{{ t('sora.save') }}
</button>
<a
v-if="generation.media_url"
:href="generation.media_url"
target="_blank"
download
class="sora-preview-btn secondary"
@click="emit('download', generation.media_url)"
>
📥 {{ t('sora.download') }}
</a>
<button class="sora-preview-btn ghost" @click="emit('close')">
{{ t('sora.closePreview') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
defineProps<{
visible: boolean
generation: SoraGeneration | null
}>()
const emit = defineEmits<{
close: []
save: [id: number]
download: [url: string]
}>()
const { t } = useI18n()
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString()
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style scoped>
.sora-preview-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.sora-preview-backdrop {
position: absolute;
inset: 0;
background: var(--sora-modal-backdrop, rgba(0, 0, 0, 0.4));
backdrop-filter: blur(4px);
}
.sora-preview-modal {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
max-height: 90vh;
max-width: 90vw;
overflow: hidden;
border-radius: 20px;
background: var(--sora-bg-secondary, #FFF);
border: 1px solid var(--sora-border-color, #E5E7EB);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: sora-modal-in 0.3s ease;
}
@keyframes sora-modal-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.sora-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--sora-border-color, #E5E7EB);
}
.sora-preview-title {
font-size: 14px;
font-weight: 500;
color: var(--sora-text-primary, #111827);
}
.sora-preview-close {
padding: 6px;
border-radius: 8px;
color: var(--sora-text-tertiary, #9CA3AF);
background: none;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-preview-close:hover {
background: var(--sora-bg-tertiary, #F3F4F6);
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-media-area {
flex: 1;
overflow: auto;
background: var(--sora-bg-primary, #F9FAFB);
padding: 8px;
}
.sora-preview-media {
max-height: 70vh;
width: 100%;
border-radius: 8px;
object-fit: contain;
}
.sora-preview-footer {
padding: 16px 20px;
border-top: 1px solid var(--sora-border-color, #E5E7EB);
}
.sora-preview-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--sora-text-tertiary, #9CA3AF);
margin-bottom: 8px;
}
.sora-preview-model-tag {
padding: 2px 8px;
background: var(--sora-bg-tertiary, #F3F4F6);
border-radius: 9999px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px;
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-prompt {
font-size: 13px;
color: var(--sora-text-secondary, #6B7280);
line-height: 1.5;
margin-bottom: 16px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sora-preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sora-preview-btn {
padding: 8px 16px;
border-radius: 9999px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sora-preview-btn.primary {
background: var(--sora-accent-gradient);
color: white;
}
.sora-preview-btn.primary:hover {
box-shadow: var(--sora-shadow-glow);
}
.sora-preview-btn.secondary {
background: var(--sora-bg-tertiary, #F3F4F6);
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-btn.secondary:hover {
background: var(--sora-bg-hover, #E5E7EB);
color: var(--sora-text-primary, #111827);
}
.sora-preview-btn.ghost {
background: transparent;
color: var(--sora-text-tertiary, #9CA3AF);
margin-left: auto;
}
.sora-preview-btn.ghost:hover {
color: var(--sora-text-secondary, #6B7280);
}
/* 过渡动画 */
.sora-modal-enter-active,
.sora-modal-leave-active {
transition: opacity 0.2s ease;
}
.sora-modal-enter-from,
.sora-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="sora-no-storage-warning">
<span></span>
<div>
<p class="sora-no-storage-title">{{ t('sora.noStorageWarningTitle') }}</p>
<p class="sora-no-storage-desc">{{ t('sora.noStorageWarningDesc') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.sora-no-storage-warning {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 20px;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 12px;
font-size: 13px;
}
.sora-no-storage-title {
font-weight: 600;
color: var(--sora-warning, #F59E0B);
margin-bottom: 4px;
}
.sora-no-storage-desc {
color: var(--sora-text-secondary, #A0A0A0);
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,609 @@
<template>
<div
class="sora-task-card"
:class="{
cancelled: generation.status === 'cancelled',
'countdown-warning': isUpstream && !isExpired && remainingMs <= 2 * 60 * 1000
}"
>
<!-- 头部状态 + 模型 + 取消按钮 -->
<div class="sora-task-header">
<div class="sora-task-status">
<span class="sora-status-dot" :class="statusDotClass" />
<span class="sora-status-label" :class="statusLabelClass">{{ statusText }}</span>
</div>
<div class="sora-task-header-right">
<span class="sora-model-tag">{{ generation.model }}</span>
<button
v-if="generation.status === 'pending' || generation.status === 'generating'"
class="sora-cancel-btn"
@click="emit('cancel', generation.id)"
>
{{ t('sora.cancel') }}
</button>
</div>
</div>
<!-- 提示词 -->
<div class="sora-task-prompt" :class="{ 'line-through': generation.status === 'cancelled' }">
{{ generation.prompt }}
</div>
<!-- 错误分类失败时 -->
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-category">
{{ t('sora.errorCategory') }}
</div>
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-message">
{{ generation.error_message }}
</div>
<!-- 进度条排队/生成/失败时 -->
<div v-if="showProgress" class="sora-task-progress-wrapper">
<div class="sora-task-progress-bar">
<div
class="sora-task-progress-fill"
:class="progressFillClass"
:style="{ width: progressWidth }"
/>
</div>
<div v-if="generation.status !== 'failed'" class="sora-task-progress-info">
<span>{{ progressInfoText }}</span>
<span>{{ progressInfoRight }}</span>
</div>
</div>
<!-- 完成预览区 -->
<div v-if="generation.status === 'completed' && generation.media_url" class="sora-task-preview">
<video
v-if="generation.media_type === 'video'"
:src="generation.media_url"
class="sora-task-preview-media"
muted
loop
@mouseenter="($event.target as HTMLVideoElement).play()"
@mouseleave="($event.target as HTMLVideoElement).pause()"
/>
<img
v-else
:src="generation.media_url"
class="sora-task-preview-media"
alt=""
/>
</div>
<!-- 完成占位预览 media_url -->
<div v-else-if="generation.status === 'completed' && !generation.media_url" class="sora-task-preview">
<div class="sora-task-preview-placeholder">🎨</div>
</div>
<!-- 操作按钮 -->
<div v-if="showActions" class="sora-task-actions">
<!-- 已完成 -->
<template v-if="generation.status === 'completed'">
<!-- 已保存标签 -->
<span v-if="generation.storage_type !== 'upstream'" class="sora-saved-badge">
{{ t('sora.savedToCloud') }}
</span>
<!-- 保存到存储按钮upstream -->
<button
v-if="generation.storage_type === 'upstream'"
class="sora-action-btn save-storage"
@click="emit('save', generation.id)"
>
{{ t('sora.save') }}
</button>
<!-- 本地下载 -->
<a
v-if="generation.media_url"
:href="generation.media_url"
target="_blank"
download
class="sora-action-btn primary"
>
📥 {{ t('sora.downloadLocal') }}
</a>
<!-- 倒计时文本upstream -->
<span v-if="isUpstream && !isExpired" class="sora-countdown-text">
{{ t('sora.upstreamCountdown', { time: countdownText }) }} {{ t('sora.canDownload') }}
</span>
<span v-if="isUpstream && isExpired" class="sora-countdown-text expired">
{{ t('sora.upstreamExpired') }}
</span>
</template>
<!-- 失败/取消 -->
<template v-if="generation.status === 'failed' || generation.status === 'cancelled'">
<button class="sora-action-btn primary" @click="emit('retry', generation)">
🔄 {{ generation.status === 'cancelled' ? t('sora.regenrate') : t('sora.retry') }}
</button>
<button class="sora-action-btn secondary" @click="emit('delete', generation.id)">
🗑 {{ t('sora.delete') }}
</button>
</template>
</div>
<!-- 倒计时进度条upstream 已完成 -->
<div v-if="isUpstream && !isExpired && generation.status === 'completed'" class="sora-countdown-bar-wrapper">
<div class="sora-countdown-bar">
<div class="sora-countdown-bar-fill" :style="{ width: countdownPercent + '%' }" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
const props = defineProps<{ generation: SoraGeneration }>()
const emit = defineEmits<{
cancel: [id: number]
delete: [id: number]
save: [id: number]
retry: [gen: SoraGeneration]
}>()
const { t } = useI18n()
// ==================== 状态样式 ====================
const statusDotClass = computed(() => {
const s = props.generation.status
return {
queued: s === 'pending',
generating: s === 'generating',
completed: s === 'completed',
failed: s === 'failed',
cancelled: s === 'cancelled'
}
})
const statusLabelClass = computed(() => statusDotClass.value)
const statusText = computed(() => {
const map: Record<string, string> = {
pending: t('sora.statusPending'),
generating: t('sora.statusGenerating'),
completed: t('sora.statusCompleted'),
failed: t('sora.statusFailed'),
cancelled: t('sora.statusCancelled')
}
return map[props.generation.status] || props.generation.status
})
// ==================== 进度条 ====================
const showProgress = computed(() => {
const s = props.generation.status
return s === 'pending' || s === 'generating' || s === 'failed'
})
const progressFillClass = computed(() => {
const s = props.generation.status
return {
generating: s === 'pending' || s === 'generating',
completed: s === 'completed',
failed: s === 'failed'
}
})
const progressWidth = computed(() => {
const s = props.generation.status
if (s === 'failed') return '100%'
if (s === 'pending') return '0%'
if (s === 'generating') {
// 根据创建时间估算进度
const created = new Date(props.generation.created_at).getTime()
const elapsed = Date.now() - created
// 假设平均 10 分钟完成,最多到 95%
const progress = Math.min(95, (elapsed / (10 * 60 * 1000)) * 100)
return `${Math.round(progress)}%`
}
return '100%'
})
const progressInfoText = computed(() => {
const s = props.generation.status
if (s === 'pending') return t('sora.queueWaiting')
if (s === 'generating') {
const created = new Date(props.generation.created_at).getTime()
const elapsed = Date.now() - created
return `${t('sora.waited')} ${formatElapsed(elapsed)}`
}
return ''
})
const progressInfoRight = computed(() => {
const s = props.generation.status
if (s === 'pending') return t('sora.waiting')
return ''
})
function formatElapsed(ms: number): string {
const s = Math.floor(ms / 1000)
const m = Math.floor(s / 60)
const sec = s % 60
return `${m}:${sec.toString().padStart(2, '0')}`
}
// ==================== 操作按钮 ====================
const showActions = computed(() => {
const s = props.generation.status
return s === 'completed' || s === 'failed' || s === 'cancelled'
})
// ==================== Upstream 倒计时 ====================
const UPSTREAM_TTL = 15 * 60 * 1000
const now = ref(Date.now())
let countdownTimer: ReturnType<typeof setInterval> | null = null
const isUpstream = computed(() =>
props.generation.status === 'completed' && props.generation.storage_type === 'upstream'
)
const expireTime = computed(() => {
if (!props.generation.completed_at) return 0
return new Date(props.generation.completed_at).getTime() + UPSTREAM_TTL
})
const remainingMs = computed(() => Math.max(0, expireTime.value - now.value))
const isExpired = computed(() => remainingMs.value <= 0)
const countdownPercent = computed(() => {
if (isExpired.value) return 0
return Math.round((remainingMs.value / UPSTREAM_TTL) * 100)
})
const countdownText = computed(() => {
const totalSec = Math.ceil(remainingMs.value / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return `${m}:${s.toString().padStart(2, '0')}`
})
onMounted(() => {
if (isUpstream.value) {
countdownTimer = setInterval(() => {
now.value = Date.now()
if (now.value >= expireTime.value && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
}
})
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<style scoped>
.sora-task-card {
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-lg, 16px);
padding: 24px;
transition: all 250ms ease;
animation: sora-fade-in 0.4s ease;
}
.sora-task-card:hover {
border-color: var(--sora-bg-hover, #333);
}
.sora-task-card.cancelled {
opacity: 0.6;
border-color: var(--sora-border-subtle, #1F1F1F);
}
.sora-task-card.countdown-warning {
border-color: var(--sora-error, #EF4444) !important;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
}
@keyframes sora-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 头部 */
.sora-task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.sora-task-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.sora-task-header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* 状态指示点 */
.sora-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.sora-status-dot.queued { background: var(--sora-text-tertiary, #666); }
.sora-status-dot.generating {
background: var(--sora-warning, #F59E0B);
animation: sora-pulse-dot 1.5s ease-in-out infinite;
}
.sora-status-dot.completed { background: var(--sora-success, #10B981); }
.sora-status-dot.failed { background: var(--sora-error, #EF4444); }
.sora-status-dot.cancelled { background: var(--sora-text-tertiary, #666); }
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* 状态标签 */
.sora-status-label.queued { color: var(--sora-text-secondary, #A0A0A0); }
.sora-status-label.generating { color: var(--sora-warning, #F59E0B); }
.sora-status-label.completed { color: var(--sora-success, #10B981); }
.sora-status-label.failed { color: var(--sora-error, #EF4444); }
.sora-status-label.cancelled { color: var(--sora-text-tertiary, #666); }
/* 模型标签 */
.sora-model-tag {
font-size: 11px;
padding: 3px 10px;
background: var(--sora-bg-tertiary, #242424);
border-radius: var(--sora-radius-full, 9999px);
color: var(--sora-text-secondary, #A0A0A0);
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
}
/* 取消按钮 */
.sora-cancel-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
background: var(--sora-bg-tertiary, #242424);
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-cancel-btn:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--sora-error, #EF4444);
}
/* 提示词 */
.sora-task-prompt {
font-size: 14px;
color: var(--sora-text-secondary, #A0A0A0);
margin-bottom: 16px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sora-task-prompt.line-through {
text-decoration: line-through;
color: var(--sora-text-tertiary, #666);
}
/* 错误分类 */
.sora-task-error-category {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(239, 68, 68, 0.1);
border-radius: var(--sora-radius-sm, 8px);
font-size: 12px;
color: var(--sora-error, #EF4444);
margin-bottom: 8px;
}
.sora-task-error-message {
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
line-height: 1.5;
margin-bottom: 12px;
}
/* 进度条 */
.sora-task-progress-wrapper {
margin-bottom: 16px;
}
.sora-task-progress-bar {
width: 100%;
height: 4px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-task-progress-fill {
height: 100%;
border-radius: 2px;
transition: width 400ms ease;
}
.sora-task-progress-fill.generating {
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
animation: sora-progress-shimmer 2s ease-in-out infinite;
}
.sora-task-progress-fill.completed {
background: var(--sora-success, #10B981);
}
.sora-task-progress-fill.failed {
background: var(--sora-error, #EF4444);
}
@keyframes sora-progress-shimmer {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.sora-task-progress-info {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: var(--sora-text-tertiary, #666);
}
/* 预览 */
.sora-task-preview {
margin-top: 16px;
border-radius: var(--sora-radius-md, 12px);
overflow: hidden;
background: var(--sora-bg-tertiary, #242424);
}
.sora-task-preview-media {
width: 100%;
height: 280px;
object-fit: cover;
display: block;
}
.sora-task-preview-placeholder {
width: 100%;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
background: var(--sora-placeholder-gradient, linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%));
font-size: 48px;
}
/* 操作按钮 */
.sora-task-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
align-items: center;
}
.sora-action-btn {
padding: 8px 20px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sora-action-btn.primary {
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
color: white;
}
.sora-action-btn.primary:hover {
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
.sora-action-btn.secondary {
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-action-btn.secondary:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
.sora-action-btn.save-storage {
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
color: white;
}
.sora-action-btn.save-storage:hover {
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3);
}
/* 已保存标签 */
.sora-saved-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.25);
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
color: var(--sora-success, #10B981);
}
/* 倒计时文本 */
.sora-countdown-text {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: var(--sora-warning, #F59E0B);
}
.sora-countdown-text.expired {
color: var(--sora-error, #EF4444);
}
/* 倒计时进度条 */
.sora-countdown-bar-wrapper {
margin-top: 12px;
}
.sora-countdown-bar {
width: 100%;
height: 3px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-countdown-bar-fill {
height: 100%;
background: var(--sora-warning, #F59E0B);
border-radius: 2px;
transition: width 1s linear;
}
.countdown-warning .sora-countdown-bar-fill {
background: var(--sora-error, #EF4444);
}
.countdown-warning .sora-countdown-text {
color: var(--sora-error, #EF4444);
}
</style>

View File

@@ -0,0 +1,738 @@
<template>
<div class="sora-creator-bar-wrapper">
<div class="sora-creator-bar">
<div class="sora-creator-bar-inner" :class="{ focused: isFocused }">
<!-- 模型选择行 -->
<div class="sora-creator-model-row">
<div class="sora-model-select-wrapper">
<select
v-model="selectedFamily"
class="sora-model-select"
@change="onFamilyChange"
>
<optgroup v-if="videoFamilies.length" :label="t('sora.videoModels')">
<option v-for="f in videoFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
</optgroup>
<optgroup v-if="imageFamilies.length" :label="t('sora.imageModels')">
<option v-for="f in imageFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
</optgroup>
</select>
<span class="sora-model-select-arrow"></span>
</div>
<!-- 凭证选择器 -->
<div class="sora-credential-select-wrapper">
<select v-model="selectedCredentialId" class="sora-model-select">
<option :value="0" disabled>{{ t('sora.selectCredential') }}</option>
<optgroup v-if="apiKeyOptions.length" :label="t('sora.apiKeys')">
<option v-for="k in apiKeyOptions" :key="'k'+k.id" :value="k.id">
{{ k.name }}{{ k.group ? ' · ' + k.group.name : '' }}
</option>
</optgroup>
<optgroup v-if="subscriptionOptions.length" :label="t('sora.subscriptions')">
<option v-for="s in subscriptionOptions" :key="'s'+s.id" :value="-s.id">
{{ s.group?.name || t('sora.subscription') }}
</option>
</optgroup>
</select>
<span class="sora-model-select-arrow"></span>
</div>
<!-- 无凭证提示 -->
<span v-if="soraCredentialEmpty" class="sora-no-storage-badge">
{{ t('sora.noCredentialHint') }}
</span>
<!-- 无存储提示 -->
<span v-if="!hasStorage" class="sora-no-storage-badge">
{{ t('sora.noStorageConfigured') }}
</span>
</div>
<!-- 参考图预览 -->
<div v-if="imagePreview" class="sora-image-preview-row">
<div class="sora-image-preview-thumb">
<img :src="imagePreview" alt="" />
<button class="sora-image-preview-remove" @click="removeImage"></button>
</div>
<span class="sora-image-preview-label">{{ t('sora.referenceImage') }}</span>
</div>
<!-- 输入框 -->
<div class="sora-creator-input-wrapper">
<textarea
ref="textareaRef"
v-model="prompt"
class="sora-creator-textarea"
:placeholder="t('sora.creatorPlaceholder')"
rows="1"
@input="autoResize"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.ctrl="submit"
@keydown.enter.meta="submit"
/>
</div>
<!-- 底部工具行 -->
<div class="sora-creator-tools-row">
<div class="sora-creator-tools-left">
<!-- 方向选择根据所选模型家族支持的方向动态渲染 -->
<template v-if="availableAspects.length > 0">
<button
v-for="a in availableAspects"
:key="a.value"
class="sora-tool-btn"
:class="{ active: currentAspect === a.value }"
@click="currentAspect = a.value"
>
<span class="sora-tool-btn-icon">{{ a.icon }}</span> {{ a.label }}
</button>
<span v-if="availableDurations.length > 0" class="sora-tool-divider" />
</template>
<!-- 时长选择根据所选模型家族支持的时长动态渲染 -->
<template v-if="availableDurations.length > 0">
<button
v-for="d in availableDurations"
:key="d"
class="sora-tool-btn"
:class="{ active: currentDuration === d }"
@click="currentDuration = d"
>
{{ d }}s
</button>
<span class="sora-tool-divider" />
</template>
<!-- 视频数量官方 Videos 1/2/3 -->
<template v-if="availableVideoCounts.length > 0">
<button
v-for="count in availableVideoCounts"
:key="count"
class="sora-tool-btn"
:class="{ active: currentVideoCount === count }"
@click="currentVideoCount = count"
>
{{ count }}
</button>
<span class="sora-tool-divider" />
</template>
<!-- 图片上传 -->
<button class="sora-upload-btn" :title="t('sora.uploadReference')" @click="triggerFileInput">
📎
</button>
<input
ref="fileInputRef"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="onFileChange"
/>
</div>
<!-- 活跃任务计数 -->
<span v-if="activeTaskCount > 0" class="sora-active-tasks-label">
<span class="sora-pulse-indicator" />
<span>{{ t('sora.generatingCount', { current: activeTaskCount, max: maxConcurrentTasks }) }}</span>
</span>
<!-- 生成按钮 -->
<button
class="sora-generate-btn"
:class="{ 'max-reached': isMaxReached }"
:disabled="!canSubmit || generating || isMaxReached"
@click="submit"
>
<span class="sora-generate-btn-icon"></span>
<span>{{ generating ? t('sora.generating') : t('sora.generate') }}</span>
</button>
</div>
</div>
</div>
<!-- 文件大小错误 -->
<p v-if="imageError" class="sora-image-error">{{ imageError }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAPI, { type SoraModelFamily, type GenerateRequest } from '@/api/sora'
import keysAPI from '@/api/keys'
import { useSubscriptionStore } from '@/stores/subscriptions'
import type { ApiKey, UserSubscription } from '@/types'
const MAX_IMAGE_SIZE = 20 * 1024 * 1024
/** 方向显示配置 */
const ASPECT_META: Record<string, { icon: string; label: string }> = {
landscape: { icon: '▬', label: '横屏' },
portrait: { icon: '▮', label: '竖屏' },
square: { icon: '◻', label: '方形' }
}
const props = defineProps<{
generating: boolean
activeTaskCount: number
maxConcurrentTasks: number
}>()
const emit = defineEmits<{
generate: [req: GenerateRequest]
fillPrompt: [prompt: string]
}>()
const { t } = useI18n()
const prompt = ref('')
const families = ref<SoraModelFamily[]>([])
const selectedFamily = ref('')
const currentAspect = ref('landscape')
const currentDuration = ref(10)
const currentVideoCount = ref(1)
const isFocused = ref(false)
const imagePreview = ref<string | null>(null)
const imageError = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const hasStorage = ref(true)
// 凭证相关状态
const apiKeyOptions = ref<ApiKey[]>([])
const subscriptionOptions = ref<UserSubscription[]>([])
const selectedCredentialId = ref<number>(0) // >0 = api_key.id, <0 = -subscription.id
const soraCredentialEmpty = computed(() =>
apiKeyOptions.value.length === 0 && subscriptionOptions.value.length === 0
)
// 按类型分组
const videoFamilies = computed(() => families.value.filter(f => f.type === 'video'))
const imageFamilies = computed(() => families.value.filter(f => f.type === 'image'))
// 当前选中的家族对象
const currentFamily = computed(() => families.value.find(f => f.id === selectedFamily.value))
// 当前家族支持的方向列表
const availableAspects = computed(() => {
const fam = currentFamily.value
if (!fam?.orientations?.length) return []
return fam.orientations
.map(o => ({ value: o, ...(ASPECT_META[o] || { icon: '?', label: o }) }))
})
// 当前家族支持的时长列表
const availableDurations = computed(() => currentFamily.value?.durations ?? [])
const availableVideoCounts = computed(() => (currentFamily.value?.type === 'video' ? [1, 2, 3] : []))
const isMaxReached = computed(() => props.activeTaskCount >= props.maxConcurrentTasks)
const canSubmit = computed(() =>
prompt.value.trim().length > 0 && selectedFamily.value && selectedCredentialId.value !== 0
)
/** 构建最终 model IDfamily + orientation + duration */
function buildModelID(): string {
const fam = currentFamily.value
if (!fam) return selectedFamily.value
if (fam.type === 'image') {
// 图像模型: "gpt-image"(方形)或 "gpt-image-landscape"
return currentAspect.value === 'square'
? fam.id
: `${fam.id}-${currentAspect.value}`
}
// 视频模型: "sora2-landscape-10s"
return `${fam.id}-${currentAspect.value}-${currentDuration.value}s`
}
/** 切换家族时自动调整方向和时长为首个可用值 */
function onFamilyChange() {
const fam = families.value.find(f => f.id === selectedFamily.value)
if (!fam) return
// 若当前方向不在新家族支持列表中,重置为首个
if (fam.orientations?.length && !fam.orientations.includes(currentAspect.value)) {
currentAspect.value = fam.orientations[0]
}
// 若当前时长不在新家族支持列表中,重置为首个
if (fam.durations?.length && !fam.durations.includes(currentDuration.value)) {
currentDuration.value = fam.durations[0]
}
if (fam.type !== 'video') {
currentVideoCount.value = 1
}
}
async function loadModels() {
try {
families.value = await soraAPI.getModels()
if (families.value.length > 0 && !selectedFamily.value) {
selectedFamily.value = families.value[0].id
onFamilyChange()
}
} catch (e) {
console.error('Failed to load models:', e)
}
}
async function loadStorageStatus() {
try {
const status = await soraAPI.getStorageStatus()
hasStorage.value = status.s3_enabled && status.s3_healthy
} catch {
hasStorage.value = false
}
}
async function loadSoraCredentials() {
try {
// 加载 API Keys筛选 sora 平台 + active 状态
const keysRes = await keysAPI.list(1, 100)
apiKeyOptions.value = (keysRes.items || []).filter(
(k: ApiKey) => k.status === 'active' && k.group?.platform === 'sora'
)
// 加载活跃订阅,筛选 sora 平台
const subStore = useSubscriptionStore()
const subs = await subStore.fetchActiveSubscriptions()
subscriptionOptions.value = subs.filter(
(s: UserSubscription) => s.status === 'active' && s.group?.platform === 'sora'
)
// 自动选择第一个
if (apiKeyOptions.value.length > 0) {
selectedCredentialId.value = apiKeyOptions.value[0].id
} else if (subscriptionOptions.value.length > 0) {
selectedCredentialId.value = -subscriptionOptions.value[0].id
}
} catch (e) {
console.error('Failed to load sora credentials:', e)
}
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
imageError.value = ''
if (file.size > MAX_IMAGE_SIZE) {
imageError.value = t('sora.imageTooLarge')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
input.value = ''
}
function removeImage() {
imagePreview.value = null
imageError.value = ''
}
function submit() {
if (!canSubmit.value || props.generating || isMaxReached.value) return
const modelID = buildModelID()
const req: GenerateRequest = {
model: modelID,
prompt: prompt.value.trim(),
media_type: currentFamily.value?.type || 'video'
}
if ((currentFamily.value?.type || 'video') === 'video') {
req.video_count = currentVideoCount.value
}
if (imagePreview.value) {
req.image_input = imagePreview.value
}
if (selectedCredentialId.value > 0) {
req.api_key_id = selectedCredentialId.value
}
emit('generate', req)
prompt.value = ''
imagePreview.value = null
imageError.value = ''
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
}
/** 外部调用:填充提示词 */
function fillPrompt(text: string) {
prompt.value = text
setTimeout(autoResize, 0)
textareaRef.value?.focus()
}
defineExpose({ fillPrompt })
onMounted(() => {
loadModels()
loadStorageStatus()
loadSoraCredentials()
})
</script>
<style scoped>
.sora-creator-bar-wrapper {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
background: linear-gradient(to top, var(--sora-bg-primary, #0D0D0D) 60%, transparent 100%);
padding: 20px 24px 24px;
pointer-events: none;
}
.sora-creator-bar {
max-width: 780px;
margin: 0 auto;
pointer-events: all;
}
.sora-creator-bar-inner {
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-xl, 20px);
padding: 12px 16px;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.sora-creator-bar-inner.focused {
border-color: var(--sora-accent-primary, #14b8a6);
box-shadow: 0 0 0 1px var(--sora-accent-primary, #14b8a6), var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
/* 模型选择行 */
.sora-creator-model-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 0 4px;
}
.sora-model-select-wrapper {
position: relative;
}
.sora-model-select {
appearance: none;
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
padding: 5px 28px 5px 10px;
border-radius: var(--sora-radius-sm, 8px);
font-size: 12px;
font-family: "SF Mono", "Fira Code", monospace;
cursor: pointer;
border: 1px solid transparent;
transition: all 150ms ease;
}
.sora-model-select:hover {
border-color: var(--sora-bg-hover, #333);
}
.sora-model-select:focus {
border-color: var(--sora-accent-primary, #14b8a6);
outline: none;
}
.sora-model-select option {
background: var(--sora-bg-secondary, #1A1A1A);
color: var(--sora-text-primary, #FFF);
}
.sora-model-select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 10px;
color: var(--sora-text-tertiary, #666);
}
.sora-credential-select-wrapper {
position: relative;
max-width: 200px;
}
/* 无存储提示 */
.sora-no-storage-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--sora-radius-full, 9999px);
font-size: 11px;
color: var(--sora-warning, #F59E0B);
}
/* 参考图预览 */
.sora-image-preview-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
margin-bottom: 8px;
}
.sora-image-preview-thumb {
position: relative;
width: 48px;
height: 48px;
}
.sora-image-preview-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--sora-border-color, #2A2A2A);
}
.sora-image-preview-remove {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--sora-error, #EF4444);
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.sora-image-preview-label {
font-size: 12px;
color: var(--sora-text-tertiary, #666);
}
/* 输入框 */
.sora-creator-input-wrapper {
position: relative;
}
.sora-creator-textarea {
width: 100%;
min-height: 44px;
max-height: 120px;
padding: 10px 4px;
font-size: 14px;
color: var(--sora-text-primary, #FFF);
background: transparent;
resize: none;
line-height: 1.5;
overflow-y: auto;
border: none;
outline: none;
font-family: inherit;
}
.sora-creator-textarea::placeholder {
color: var(--sora-text-muted, #4A4A4A);
}
/* 底部工具行 */
.sora-creator-tools-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 0;
border-top: 1px solid var(--sora-border-subtle, #1F1F1F);
margin-top: 4px;
padding-top: 10px;
gap: 8px;
}
.sora-creator-tools-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sora-tool-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
background: var(--sora-bg-tertiary, #242424);
border: none;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.sora-tool-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
.sora-tool-btn.active {
background: rgba(20, 184, 166, 0.15);
color: var(--sora-accent-primary, #14b8a6);
border: 1px solid rgba(20, 184, 166, 0.3);
}
.sora-tool-btn-icon {
font-size: 14px;
line-height: 1;
}
.sora-tool-divider {
width: 1px;
height: 20px;
background: var(--sora-border-color, #2A2A2A);
margin: 0 4px;
}
/* 上传按钮 */
.sora-upload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--sora-radius-sm, 8px);
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-secondary, #A0A0A0);
font-size: 16px;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-upload-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
/* 活跃任务计数 */
.sora-active-tasks-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(20, 184, 166, 0.12);
border: 1px solid rgba(20, 184, 166, 0.25);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
font-weight: 500;
color: var(--sora-accent-primary, #14b8a6);
white-space: nowrap;
animation: sora-fade-in 0.3s ease;
}
.sora-pulse-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sora-accent-primary, #14b8a6);
animation: sora-pulse-dot 1.5s ease-in-out infinite;
}
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes sora-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 生成按钮 */
.sora-generate-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 24px;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 600;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
flex-shrink: 0;
}
.sora-generate-btn:hover:not(:disabled) {
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
transform: translateY(-1px);
}
.sora-generate-btn:active:not(:disabled) {
transform: translateY(0);
}
.sora-generate-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.sora-generate-btn.max-reached {
opacity: 0.4;
cursor: not-allowed;
}
.sora-generate-btn-icon {
font-size: 16px;
}
/* 图片错误 */
.sora-image-error {
text-align: center;
font-size: 12px;
color: var(--sora-error, #EF4444);
margin-top: 8px;
pointer-events: all;
}
/* 响应式 */
@media (max-width: 600px) {
.sora-creator-bar-wrapper {
padding: 12px 12px 16px;
}
.sora-creator-tools-left {
gap: 4px;
}
.sora-tool-btn {
padding: 5px 8px;
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div v-if="quota && quota.source !== 'none'" class="sora-quota-info">
<div class="sora-quota-bar-wrapper">
<div
class="sora-quota-bar-fill"
:class="{ warning: percentage > 80, danger: percentage > 95 }"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
</div>
<span class="sora-quota-text" :class="{ warning: percentage > 80, danger: percentage > 95 }">
{{ formatBytes(quota.used_bytes) }} / {{ quota.quota_bytes === 0 ? '∞' : formatBytes(quota.quota_bytes) }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { QuotaInfo } from '@/api/sora'
const props = defineProps<{ quota: QuotaInfo }>()
const percentage = computed(() => {
if (!props.quota || props.quota.quota_bytes === 0) return 0
return (props.quota.used_bytes / props.quota.quota_bytes) * 100
})
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
</script>
<style scoped>
.sora-quota-info {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-quota-bar-wrapper {
width: 80px;
height: 4px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-quota-bar-fill {
height: 100%;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: 2px;
transition: width 400ms ease;
}
.sora-quota-bar-fill.warning {
background: var(--sora-warning, #F59E0B) !important;
}
.sora-quota-bar-fill.danger {
background: var(--sora-error, #EF4444) !important;
}
.sora-quota-text {
white-space: nowrap;
}
.sora-quota-text.warning {
color: var(--sora-warning, #F59E0B);
}
.sora-quota-text.danger {
color: var(--sora-error, #EF4444);
}
@media (max-width: 900px) {
.sora-quota-info {
display: none;
}
}
</style>

View File

@@ -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
}

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex items-center">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
statusClass
]"
>
<!-- Four-square grid icon -->
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
<span class="font-mono">{{ current }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ max }}</span>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
current: number
max: number
}>()
// Status color based on usage
const statusClass = computed(() => {
const { current, max } = props
// Full: red
if (current >= max && max > 0) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
// In use: yellow
if (current > 0) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
// Idle: gray
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
})
</script>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
})
})

View 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()
})
})

View 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)
})
})

View File

@@ -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()
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { buildModelMappingObject, getModelsByPlatform } from '../useModelWhitelist'
describe('useModelWhitelist', () => {
it('openai 模型列表包含 GPT-5.4 官方快照', () => {
const models = getModelsByPlatform('openai')
expect(models).toContain('gpt-5.4')
expect(models).toContain('gpt-5.4-2026-03-05')
})
it('antigravity 模型列表包含图片模型兼容项', () => {
const models = getModelsByPlatform('antigravity')
expect(models).toContain('gemini-3.1-flash-image')
expect(models).toContain('gemini-3-pro-image')
})
it('whitelist 模式会忽略通配符条目', () => {
const mapping = buildModelMappingObject('whitelist', ['claude-*', 'gemini-3.1-flash-image'], [])
expect(mapping).toEqual({
'gemini-3.1-flash-image': 'gemini-3.1-flash-image'
})
})
it('whitelist 模式会保留 GPT-5.4 官方快照的精确映射', () => {
const mapping = buildModelMappingObject('whitelist', ['gpt-5.4-2026-03-05'], [])
expect(mapping).toEqual({
'gpt-5.4-2026-03-05': 'gpt-5.4-2026-03-05'
})
})
})

View File

@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn()
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
generateAuthUrl: vi.fn(),
exchangeCode: vi.fn(),
refreshOpenAIToken: vi.fn(),
validateSoraSessionToken: vi.fn()
}
}
}))
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
describe('useOpenAIOAuth.buildCredentials', () => {
it('should keep client_id when token response contains it', () => {
const oauth = useOpenAIOAuth({ platform: 'sora' })
const creds = oauth.buildCredentials({
access_token: 'at',
refresh_token: 'rt',
client_id: 'app_sora_client',
expires_at: 1700000000
})
expect(creds.client_id).toBe('app_sora_client')
expect(creds.access_token).toBe('at')
expect(creds.refresh_token).toBe('rt')
})
it('should keep legacy behavior when client_id is missing', () => {
const oauth = useOpenAIOAuth({ platform: 'openai' })
const creds = oauth.buildCredentials({
access_token: 'at',
refresh_token: 'rt',
expires_at: 1700000000
})
expect(Object.prototype.hasOwnProperty.call(creds, 'client_id')).toBe(false)
expect(creds.access_token).toBe('at')
expect(creds.refresh_token).toBe('rt')
})
})

View 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 的 onUnmountedcomposable 外使用时会报错)
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()
})
})
})

View File

@@ -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'
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'session_token' | 'access_token'
export interface OAuthState {
authUrl: string

View File

@@ -83,6 +83,35 @@ export function useAntigravityOAuth() {
}
}
const validateRefreshToken = async (
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo | null> => {
if (!refreshToken.trim()) {
error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
return null
}
loading.value = true
error.value = ''
try {
const tokenInfo = await adminAPI.antigravity.refreshAntigravityToken(
refreshToken.trim(),
proxyId
)
return tokenInfo as AntigravityTokenInfo
} catch (err: any) {
error.value =
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToValidateRT')
// Don't show global error toast for batch validation to avoid spamming
// appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
let expiresAt: string | undefined
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
@@ -110,6 +139,7 @@ export function useAntigravityOAuth() {
resetState,
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
buildCredentials
}
}

View 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
}
}

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