Merge tag 'v0.1.90' into merge/upstream-v0.1.90

注册邮箱域名白名单策略上线,后台大数据场景性能大幅优化。

- 注册邮箱域名白名单:支持管理员配置允许注册的邮箱域名策略
- Keys 页面表单筛选:用户 /keys 页面支持按条件筛选 API Key
- Settings 页面分 Tab 拆分:管理后台设置页面按功能模块分 Tab 展示

- 后台大数据场景加载性能优化:仪表盘/用户/账号/Ops 页面大数据集加载显著提速
- Usage 大表分页优化:默认避免全量 COUNT(*),大幅降低分页查询耗时
- 消除重复的 normalizeAccountIDList,补充新增组件的单元测试
- 清理无用文件和过时文档,精简项目结构
- EmailVerifyView 硬编码英文字符串替换为 i18n 调用

- 修复 Anthropic 平台无限流重置时间的 429 误标记账号限流问题
- 修复自定义菜单页面管理员视角菜单不生效问题
- 修复 Ops 错误详情弹窗未展示真实上游 payload 的问题
- 修复充值/订阅菜单 icon 显示问题

# Conflicts:
#	.gitignore
#	backend/cmd/server/VERSION
#	backend/ent/group.go
#	backend/ent/runtime/runtime.go
#	backend/ent/schema/group.go
#	backend/go.sum
#	backend/internal/handler/admin/account_handler.go
#	backend/internal/handler/admin/dashboard_handler.go
#	backend/internal/pkg/usagestats/usage_log_types.go
#	backend/internal/repository/group_repo.go
#	backend/internal/repository/usage_log_repo.go
#	backend/internal/server/middleware/security_headers.go
#	backend/internal/server/router.go
#	backend/internal/service/account_usage_service.go
#	backend/internal/service/admin_service_bulk_update_test.go
#	backend/internal/service/dashboard_service.go
#	backend/internal/service/gateway_service.go
#	frontend/src/api/admin/dashboard.ts
#	frontend/src/components/account/BulkEditAccountModal.vue
#	frontend/src/components/charts/GroupDistributionChart.vue
#	frontend/src/components/layout/AppSidebar.vue
#	frontend/src/i18n/locales/en.ts
#	frontend/src/i18n/locales/zh.ts
#	frontend/src/views/admin/GroupsView.vue
#	frontend/src/views/admin/SettingsView.vue
#	frontend/src/views/admin/UsageView.vue
#	frontend/src/views/user/PurchaseSubscriptionView.vue
This commit is contained in:
erio
2026-03-04 19:58:38 +08:00
461 changed files with 63392 additions and 6617 deletions

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

@@ -36,6 +36,7 @@ export async function list(
status?: string
group?: string
search?: string
lite?: string
},
options?: {
signal?: AbortSignal
@@ -66,6 +67,7 @@ export async function listWithEtag(
type?: string
status?: string
search?: string
lite?: string
},
options?: {
signal?: AbortSignal
@@ -369,6 +371,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
@@ -556,6 +574,7 @@ export const accountsAPI = {
clearError,
getUsage,
getTodayStats,
getBatchTodayStats,
clearRateLimit,
getTempUnschedulableStatus,
resetTempUnschedulable,

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

@@ -10,7 +10,8 @@ import type {
ModelStat,
GroupStat,
ApiKeyUsageTrendPoint,
UserUsageTrendPoint
UserUsageTrendPoint,
UsageRequestType
} from '@/types'
/**
@@ -50,6 +51,7 @@ export interface TrendParams {
model?: string
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
@@ -79,6 +81,7 @@ export interface ModelStatsParams {
model?: string
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
@@ -106,6 +109,7 @@ export interface GroupStatsParams {
api_key_id?: number
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
@@ -116,6 +120,31 @@ export interface GroupStatsResponse {
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
@@ -126,6 +155,16 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
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
}
@@ -229,6 +268,7 @@ export const dashboardAPI = {
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

@@ -20,6 +20,8 @@ import antigravityAPI from './antigravity'
import userAttributesAPI from './userAttributes'
import opsAPI from './ops'
import errorPassthroughAPI from './errorPassthrough'
import dataManagementAPI from './dataManagement'
import apiKeysAPI from './apiKeys'
/**
* Unified admin API object for convenient access
@@ -41,7 +43,9 @@ export const adminAPI = {
antigravity: antigravityAPI,
userAttributes: userAttributesAPI,
ops: opsAPI,
errorPassthrough: errorPassthroughAPI
errorPassthrough: errorPassthroughAPI,
dataManagement: dataManagementAPI,
apiKeys: apiKeysAPI
}
export {
@@ -61,7 +65,9 @@ export {
antigravityAPI,
userAttributesAPI,
opsAPI,
errorPassthroughAPI
errorPassthroughAPI,
dataManagementAPI,
apiKeysAPI
}
export default adminAPI
@@ -69,3 +75,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,13 @@ 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 {
@@ -1004,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'
@@ -1329,6 +1354,7 @@ async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise<
}
export const opsAPI = {
getDashboardSnapshotV2,
getDashboardOverview,
getThroughputTrend,
getLatencyHistogram,

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,142 @@ export async function updateStreamTimeoutSettings(
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 +418,15 @@ export const settingsAPI = {
regenerateAdminApiKey,
deleteAdminApiKey,
getStreamTimeoutSettings,
updateStreamTimeoutSettings
updateStreamTimeoutSettings,
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

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

@@ -52,6 +52,25 @@
<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>
</div>
</template>
@@ -125,19 +144,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 +180,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 +203,89 @@ 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')
}
})
// 格式化费用显示
const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0'

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

@@ -398,7 +398,9 @@ const antigravity3ProUsageFromAPI = computed(() =>
const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
// Gemini Image from API
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3.1-flash-image']))
const antigravity3ImageUsageFromAPI = computed(() =>
getAntigravityUsageFromAPI(['gemini-3.1-flash-image', 'gemini-3-pro-image'])
)
// Claude from API (all Claude model variants)
const antigravityClaudeUsageFromAPI = computed(() =>

View File

@@ -585,6 +585,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">
@@ -669,7 +795,7 @@ import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform } 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'
@@ -682,6 +808,7 @@ interface Props {
show: boolean
accountIds: number[]
selectedPlatforms: AccountPlatform[]
selectedTypes: AccountType[]
proxies: ProxyConfig[]
groups: AdminGroup[]
}
@@ -698,9 +825,18 @@ 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-'],
antigravity: ['claude-', 'gemini-', 'gpt-oss-', 'tab_'],
openai: ['gpt-'],
gemini: ['gemini-'],
sora: []
@@ -737,6 +873,7 @@ 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)
@@ -756,6 +893,16 @@ 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 + Gemini)
const allModels = [
@@ -781,6 +928,8 @@ const allModels = [
{ 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' }
]
@@ -859,6 +1008,18 @@ const presetMappings = [
to: 'claude-sonnet-4-5-20250929',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
},
{
label: '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',
@@ -1095,6 +1256,34 @@ 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
}
@@ -1129,11 +1318,7 @@ const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise
if (!result.has_risk) return true
pendingUpdatesForConfirm.value = built
mixedChannelWarningMessage.value = t('admin.accounts.mixedChannelWarning', {
groupName: result.details?.group_name,
currentPlatform: result.details?.current_platform,
otherPlatform: result.details?.other_platform
})
mixedChannelWarningMessage.value = result.message || t('admin.accounts.bulkEdit.failed')
showMixedChannelWarning.value = true
return false
} catch (error: any) {
@@ -1158,7 +1343,9 @@ const handleSubmit = async () => {
enablePriority.value ||
enableRateMultiplier.value ||
enableStatus.value ||
enableGroups.value
enableGroups.value ||
enableRpmLimit.value ||
userMsgQueueMode.value !== null
if (!hasAnyFieldEnabled) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
@@ -1207,11 +1394,7 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
if (error.status === 409 && error.error === 'mixed_channel_warning') {
pendingUpdatesForConfirm.value = baseUpdates
mixedChannelWarningMessage.value = t('admin.accounts.mixedChannelWarning', {
groupName: error.details?.group_name,
currentPlatform: error.details?.current_platform,
otherPlatform: error.details?.other_platform
})
mixedChannelWarningMessage.value = error.message
showMixedChannelWarning.value = true
} else {
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
@@ -1251,6 +1434,7 @@ watch(
enableRateMultiplier.value = false
enableStatus.value = false
enableGroups.value = false
enableRpmLimit.value = false
// Reset all values
baseUrl.value = ''
@@ -1266,6 +1450,11 @@ watch(
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

View File

@@ -175,13 +175,13 @@
<!-- Account Type Selection (Sora) -->
<div v-if="form.platform === 'sora'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-1 gap-3" data-tour="account-form-type">
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
<button
type="button"
@click="accountCategory = 'oauth-based'"
@click="soraAccountType = 'oauth'; accountCategory = 'oauth-based'; addMethod = 'oauth'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
soraAccountType === 'oauth'
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
]"
@@ -189,7 +189,7 @@
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
soraAccountType === 'oauth'
? 'bg-rose-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
@@ -201,6 +201,31 @@
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
</div>
</button>
<button
type="button"
@click="soraAccountType = 'apikey'; accountCategory = 'apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
soraAccountType === 'apikey'
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
soraAccountType === 'apikey'
? 'bg-rose-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="link" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.soraApiKey') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.soraApiKeyHint') }}</span>
</div>
</button>
</div>
</div>
@@ -879,14 +904,14 @@
type="text"
class="input"
:placeholder="
form.platform === 'openai'
form.platform === 'openai' || form.platform === 'sora'
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
"
/>
<p class="input-hint">{{ baseUrlHint }}</p>
<p class="input-hint">{{ form.platform === 'sora' ? t('admin.accounts.soraUpstreamBaseUrlHint') : baseUrlHint }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
@@ -1511,6 +1536,119 @@
</div>
</div>
<!-- RPM Limit -->
<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.quotaControl.rpmLimit.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}
</p>
</div>
<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-4">
<div>
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
<input
v-model.number="baseRpm"
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">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
<div class="flex gap-2">
<button
type="button"
@click="rpmStrategy = 'tiered'"
:class="[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === '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'
]"
>
<div class="text-center">
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</div>
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTieredHint') }}</div>
</div>
</button>
<button
type="button"
@click="rpmStrategy = 'sticky_exempt'"
:class="[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === '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'
]"
>
<div class="text-center">
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</div>
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint') }}</div>
</div>
</button>
</div>
</div>
<div v-if="rpmStrategy === 'tiered'">
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
<input
v-model.number="rpmStickyBuffer"
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>
<!-- 用户消息限速模式独立于 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 = 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>
<!-- TLS Fingerprint -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="flex items-center justify-between">
@@ -1669,6 +1807,27 @@
</div>
</div>
<!-- OpenAI WS Mode 三态off/shared/dedicated -->
<div
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.wsMode') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.wsModeDesc') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.wsModeConcurrencyHint') }}
</p>
</div>
<div class="w-52">
<Select v-model="openaiResponsesWebSocketV2Mode" :options="openAIWSModeOptions" />
</div>
</div>
</div>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="form.platform === 'anthropic' && accountCategory === 'apikey'"
@@ -2173,6 +2332,7 @@ import type {
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
@@ -2180,6 +2340,13 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import {
OPENAI_WS_MODE_DEDICATED,
OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED,
isOpenAIWSModeEnabled,
type OpenAIWSMode
} from '@/utils/openaiWsMode'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component
@@ -2301,10 +2468,13 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
const soraAccountType = ref<'oauth' | 'apikey'>('oauth') // For sora: oauth or apikey (upstream)
const upstreamBaseUrl = ref('') // For upstream type: base URL
const upstreamApiKey = ref('') // For upstream type: API key
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
@@ -2336,6 +2506,16 @@ const windowCostStickyReserve = ref<number | null>(null)
const sessionLimitEnabled = ref(false)
const maxSessions = ref<number | null>(null)
const sessionIdleTimeout = ref<number | null>(null)
const rpmLimitEnabled = ref(false)
const baseRpm = ref<number | null>(null)
const rpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
const rpmStickyBuffer = ref<number | null>(null)
const userMsgQueueMode = ref('')
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') },
])
const tlsFingerprintEnabled = ref(false)
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
@@ -2359,6 +2539,28 @@ const geminiSelectedTier = computed(() => {
}
})
const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
{ value: OPENAI_WS_MODE_SHARED, label: t('admin.accounts.openai.wsModeShared') },
{ value: OPENAI_WS_MODE_DEDICATED, label: t('admin.accounts.openai.wsModeDedicated') }
])
const openaiResponsesWebSocketV2Mode = computed({
get: () => {
if (form.platform === 'openai' && accountCategory.value === 'apikey') {
return openaiAPIKeyResponsesWebSocketV2Mode.value
}
return openaiOAuthResponsesWebSocketV2Mode.value
},
set: (mode: OpenAIWSMode) => {
if (form.platform === 'openai' && accountCategory.value === 'apikey') {
openaiAPIKeyResponsesWebSocketV2Mode.value = mode
return
}
openaiOAuthResponsesWebSocketV2Mode.value = mode
}
})
const isOpenAIModelRestrictionDisabled = computed(() =>
form.platform === 'openai' && openaiPassthroughEnabled.value
)
@@ -2490,15 +2692,20 @@ watch(
}
)
// Sync form.type based on accountCategory, addMethod, and antigravityAccountType
// Sync form.type based on accountCategory, addMethod, and platform-specific type
watch(
[accountCategory, addMethod, antigravityAccountType],
([category, method, agType]) => {
[accountCategory, addMethod, antigravityAccountType, soraAccountType],
([category, method, agType, soraType]) => {
// Antigravity upstream 类型(实际创建为 apikey
if (form.platform === 'antigravity' && agType === 'upstream') {
form.type = 'apikey'
return
}
// Sora apikey 类型(上游透传)
if (form.platform === 'sora' && soraType === 'apikey') {
form.type = 'apikey'
return
}
if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
} else {
@@ -2541,12 +2748,16 @@ watch(
interceptWarmupRequests.value = false
}
if (newPlatform === 'sora') {
// 默认 OAuth但允许用户选择 API Key
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
form.type = 'oauth'
soraAccountType.value = 'oauth'
}
if (newPlatform !== 'openai') {
openaiPassthroughEnabled.value = false
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
}
if (newPlatform !== 'anthropic') {
@@ -2918,6 +3129,8 @@ const resetForm = () => {
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
// Reset quota control state
@@ -2927,6 +3140,11 @@ const resetForm = () => {
sessionLimitEnabled.value = false
maxSessions.value = null
sessionIdleTimeout.value = null
rpmLimitEnabled.value = false
baseRpm.value = null
rpmStrategy.value = 'tiered'
rpmStickyBuffer.value = null
userMsgQueueMode.value = ''
tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
@@ -2962,6 +3180,13 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
}
const extra: Record<string, unknown> = { ...(base || {}) }
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
extra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value)
extra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
// 清理兼容旧键,统一改用分类型开关。
delete extra.responses_websockets_v2_enabled
delete extra.openai_ws_enabled
if (openaiPassthroughEnabled.value) {
extra.openai_passthrough = true
} else {
@@ -3007,6 +3232,12 @@ const buildSoraExtra = (
delete extra.openai_passthrough
delete extra.openai_oauth_passthrough
delete extra.codex_cli_only
delete extra.openai_oauth_responses_websockets_v2_mode
delete extra.openai_apikey_responses_websockets_v2_mode
delete extra.openai_oauth_responses_websockets_v2_enabled
delete extra.openai_apikey_responses_websockets_v2_enabled
delete extra.responses_websockets_v2_enabled
delete extra.openai_ws_enabled
return Object.keys(extra).length > 0 ? extra : undefined
}
@@ -3102,9 +3333,22 @@ const handleSubmit = async () => {
return
}
// Sora apikey 账号 base_url 必填 + scheme 校验
if (form.platform === 'sora') {
const soraBaseUrl = apiKeyBaseUrl.value.trim()
if (!soraBaseUrl) {
appStore.showError(t('admin.accounts.soraBaseUrlRequired'))
return
}
if (!soraBaseUrl.startsWith('http://') && !soraBaseUrl.startsWith('https://')) {
appStore.showError(t('admin.accounts.soraBaseUrlInvalidScheme'))
return
}
}
// Determine default base URL based on platform
const defaultBaseUrl =
(form.platform === 'openai' || form.platform === 'sora')
form.platform === 'openai'
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
@@ -3358,6 +3602,7 @@ const handleOpenAIExchange = async (authCode: string) => {
const soraCredentials = {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
client_id: credentials.client_id,
expires_at: credentials.expires_at
}
@@ -3462,6 +3707,7 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
const soraCredentials = {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
client_id: credentials.client_id,
expires_at: credentials.expires_at
}
const soraName = shouldCreateOpenAI ? `${accountName} (Sora)` : accountName
@@ -3808,6 +4054,20 @@ const handleAnthropicExchange = async (authCode: string) => {
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
}
// Add RPM limit settings
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
extra.base_rpm = baseRpm.value
extra.rpm_strategy = rpmStrategy.value
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
extra.rpm_sticky_buffer = rpmStickyBuffer.value
}
}
// UMQ mode独立于 RPM
if (userMsgQueueMode.value) {
extra.user_msg_queue_mode = userMsgQueueMode.value
}
// Add TLS fingerprint settings
if (tlsFingerprintEnabled.value) {
extra.enable_tls_fingerprint = true
@@ -3906,6 +4166,20 @@ const handleCookieAuth = async (sessionKey: string) => {
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
}
// Add RPM limit settings
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
extra.base_rpm = baseRpm.value
extra.rpm_strategy = rpmStrategy.value
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
extra.rpm_sticky_buffer = rpmStickyBuffer.value
}
}
// UMQ mode独立于 RPM
if (userMsgQueueMode.value) {
extra.user_msg_queue_mode = userMsgQueueMode.value
}
// Add TLS fingerprint settings
if (tlsFingerprintEnabled.value) {
extra.enable_tls_fingerprint = true

View File

@@ -35,7 +35,7 @@
type="text"
class="input"
:placeholder="
account.platform === 'openai'
account.platform === 'openai' || account.platform === 'sora'
? 'https://api.openai.com'
: account.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
@@ -53,7 +53,7 @@
type="password"
class="input font-mono"
:placeholder="
account.platform === 'openai'
account.platform === 'openai' || account.platform === 'sora'
? 'sk-proj-...'
: account.platform === 'gemini'
? 'AIza...'
@@ -708,6 +708,27 @@
</div>
</div>
<!-- OpenAI WS Mode 三态off/shared/dedicated -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.wsMode') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.wsModeDesc') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.wsModeConcurrencyHint') }}
</p>
</div>
<div class="w-52">
<Select v-model="openaiResponsesWebSocketV2Mode" :options="openAIWSModeOptions" />
</div>
</div>
</div>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
@@ -925,6 +946,119 @@
</div>
</div>
<!-- RPM Limit -->
<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.quotaControl.rpmLimit.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}
</p>
</div>
<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-4">
<div>
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
<input
v-model.number="baseRpm"
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">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
<div class="flex gap-2">
<button
type="button"
@click="rpmStrategy = 'tiered'"
:class="[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === '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'
]"
>
<div class="text-center">
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</div>
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTieredHint') }}</div>
</div>
</button>
<button
type="button"
@click="rpmStrategy = 'sticky_exempt'"
:class="[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === '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'
]"
>
<div class="text-center">
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</div>
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint') }}</div>
</div>
</button>
</div>
</div>
<div v-if="rpmStrategy === 'tiered'">
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
<input
v-model.number="rpmStickyBuffer"
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>
<!-- 用户消息限速模式独立于 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 = 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>
<!-- TLS Fingerprint -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="flex items-center justify-between">
@@ -1138,6 +1272,14 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import {
OPENAI_WS_MODE_DEDICATED,
OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED,
isOpenAIWSModeEnabled,
type OpenAIWSMode,
resolveOpenAIWSModeFromExtra
} from '@/utils/openaiWsMode'
import {
getPresetMappingsByPlatform,
commonErrorCodes,
@@ -1222,6 +1364,16 @@ const windowCostStickyReserve = ref<number | null>(null)
const sessionLimitEnabled = ref(false)
const maxSessions = ref<number | null>(null)
const sessionIdleTimeout = ref<number | null>(null)
const rpmLimitEnabled = ref(false)
const baseRpm = ref<number | null>(null)
const rpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
const rpmStickyBuffer = ref<number | null>(null)
const userMsgQueueMode = ref('')
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') },
])
const tlsFingerprintEnabled = ref(false)
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
@@ -1229,8 +1381,30 @@ const cacheTTLOverrideTarget = ref<string>('5m')
// OpenAI 自动透传开关OAuth/API Key
const openaiPassthroughEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
{ value: OPENAI_WS_MODE_SHARED, label: t('admin.accounts.openai.wsModeShared') },
{ value: OPENAI_WS_MODE_DEDICATED, label: t('admin.accounts.openai.wsModeDedicated') }
])
const openaiResponsesWebSocketV2Mode = computed({
get: () => {
if (props.account?.type === 'apikey') {
return openaiAPIKeyResponsesWebSocketV2Mode.value
}
return openaiOAuthResponsesWebSocketV2Mode.value
},
set: (mode: OpenAIWSMode) => {
if (props.account?.type === 'apikey') {
openaiAPIKeyResponsesWebSocketV2Mode.value = mode
return
}
openaiOAuthResponsesWebSocketV2Mode.value = mode
}
})
const isOpenAIModelRestrictionDisabled = computed(() =>
props.account?.platform === 'openai' && openaiPassthroughEnabled.value
)
@@ -1269,7 +1443,7 @@ const tempUnschedPresets = computed(() => [
// Computed: default base URL based on platform
const defaultBaseUrl = computed(() => {
if (props.account?.platform === 'openai') return 'https://api.openai.com'
if (props.account?.platform === 'openai' || props.account?.platform === 'sora') return 'https://api.openai.com'
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
return 'https://api.anthropic.com'
})
@@ -1336,10 +1510,24 @@ watch(
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
openaiPassthroughEnabled.value = false
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
modeKey: 'openai_oauth_responses_websockets_v2_mode',
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
defaultMode: OPENAI_WS_MODE_OFF
})
openaiAPIKeyResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
modeKey: 'openai_apikey_responses_websockets_v2_mode',
enabledKey: 'openai_apikey_responses_websockets_v2_enabled',
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
defaultMode: OPENAI_WS_MODE_OFF
})
if (newAccount.type === 'oauth') {
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
}
@@ -1389,7 +1577,7 @@ watch(
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
const platformDefaultUrl =
newAccount.platform === 'openai'
newAccount.platform === 'openai' || newAccount.platform === 'sora'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
@@ -1435,7 +1623,7 @@ watch(
editBaseUrl.value = (credentials.base_url as string) || ''
} else {
const platformDefaultUrl =
newAccount.platform === 'openai'
newAccount.platform === 'openai' || newAccount.platform === 'sora'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
@@ -1645,6 +1833,11 @@ function loadQuotaControlSettings(account: Account) {
sessionLimitEnabled.value = false
maxSessions.value = null
sessionIdleTimeout.value = null
rpmLimitEnabled.value = false
baseRpm.value = null
rpmStrategy.value = 'tiered'
rpmStickyBuffer.value = null
userMsgQueueMode.value = ''
tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
@@ -1668,6 +1861,17 @@ function loadQuotaControlSettings(account: Account) {
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
}
// RPM limit
if (account.base_rpm != null && account.base_rpm > 0) {
rpmLimitEnabled.value = true
baseRpm.value = account.base_rpm
rpmStrategy.value = (account.rpm_strategy as 'tiered' | 'sticky_exempt') || 'tiered'
rpmStickyBuffer.value = account.rpm_sticky_buffer ?? null
}
// UMQ mode独立于 RPM 加载,防止编辑无 RPM 账号时丢失已有配置)
userMsgQueueMode.value = account.user_msg_queue_mode ?? ''
// Load TLS fingerprint setting
if (account.enable_tls_fingerprint === true) {
tlsFingerprintEnabled.value = true
@@ -1978,6 +2182,29 @@ const handleSubmit = async () => {
delete newExtra.session_idle_timeout_minutes
}
// RPM limit settings
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
newExtra.base_rpm = baseRpm.value
newExtra.rpm_strategy = rpmStrategy.value
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
newExtra.rpm_sticky_buffer = rpmStickyBuffer.value
} else {
delete newExtra.rpm_sticky_buffer
}
} else {
delete newExtra.base_rpm
delete newExtra.rpm_strategy
delete newExtra.rpm_sticky_buffer
}
// UMQ mode独立于 RPM 保存)
if (userMsgQueueMode.value) {
newExtra.user_msg_queue_mode = userMsgQueueMode.value
} else {
delete newExtra.user_msg_queue_mode
}
delete newExtra.user_msg_queue_enabled // 清理旧字段
// TLS fingerprint setting
if (tlsFingerprintEnabled.value) {
newExtra.enable_tls_fingerprint = true
@@ -2021,6 +2248,12 @@ const handleSubmit = async () => {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true
newExtra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
newExtra.openai_apikey_responses_websockets_v2_mode = openaiAPIKeyResponsesWebSocketV2Mode.value
newExtra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value)
newExtra.openai_apikey_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiAPIKeyResponsesWebSocketV2Mode.value)
delete newExtra.responses_websockets_v2_enabled
delete newExtra.openai_ws_enabled
if (openaiPassthroughEnabled.value) {
newExtra.openai_passthrough = true
} else {

View File

@@ -171,7 +171,7 @@
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<Icon name="key" size="sm" class="text-blue-500" />
Session Token
{{ t(getOAuthKey('sessionTokenRawLabel')) }}
<span
v-if="parsedSessionTokenCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
@@ -183,8 +183,33 @@
v-model="sessionTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t(getOAuthKey('sessionTokenPlaceholder'))"
: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"
@@ -193,6 +218,54 @@
</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"
@@ -205,7 +278,7 @@
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !sessionTokenInput.trim()"
:disabled="loading || parsedSessionTokenCount === 0"
@click="handleValidateSessionToken"
>
<svg
@@ -669,6 +742,7 @@
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'
@@ -781,13 +855,26 @@ const parsedRefreshTokenCount = computed(() => {
.filter((rt) => rt).length
})
const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value))
const parsedSessionTokenCount = computed(() => {
return sessionTokenInput.value
.split('\n')
.map((st) => st.trim())
.filter((st) => st).length
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')
@@ -863,11 +950,19 @@ const handleValidateRefreshToken = () => {
}
const handleValidateSessionToken = () => {
if (sessionTokenInput.value.trim()) {
emit('validate-session-token', sessionTokenInput.value.trim())
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())

View File

@@ -0,0 +1,70 @@
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')
})
})

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

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

@@ -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>
@@ -271,6 +271,7 @@
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'
@@ -289,6 +290,21 @@ const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<AdminUsageLog | null>(null)
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`

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

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

@@ -29,9 +29,9 @@
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title="group.group_name"
:title="group.group_name || String(group.group_id)"
>
{{ group.group_name }}
{{ 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) }}
@@ -93,7 +93,7 @@ const chartData = computed(() => {
if (!props.groupStats?.length) return null
return {
labels: props.groupStats.map((g) => g.group_name),
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
datasets: [
{
data: props.groupStats.map((g) => g.total_tokens),

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

@@ -268,6 +268,7 @@ const clientTabs = computed((): TabConfig[] => {
case 'openai':
return [
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'codex-ws', label: t('keys.useKeyModal.cliTabs.codexCliWs'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'gemini':
@@ -306,7 +307,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
@@ -401,6 +402,9 @@ const currentFiles = computed((): FileConfig[] => {
switch (props.platform) {
case 'openai':
if (activeClientTab.value === 'codex-ws') {
return generateOpenAIWsFiles(baseUrl, apiKey)
}
return generateOpenAIFiles(baseUrl, apiKey)
case 'gemini':
return [generateGeminiCliContent(baseUrl, apiKey)]
@@ -524,6 +528,47 @@ 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 = "sub2api"
model = "gpt-5.3-codex"
model_reasoning_effort = "high"
network_access = "enabled"
disable_response_storage = true
windows_wsl_setup_acknowledged = true
model_verbosity = "high"
[model_providers.sub2api]
name = "sub2api"
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]: {

View File

@@ -194,6 +194,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'
@@ -204,6 +205,7 @@ const route = useRoute()
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const adminSettingsStore = useAdminSettingsStore()
const onboardingStore = useOnboardingStore()
const user = computed(() => authStore.user)
@@ -237,6 +239,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

@@ -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()
@@ -294,17 +306,22 @@ const RechargeSubscriptionIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
{ fill: 'currentColor', viewBox: '0 0 1024 1024' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M2.25 7.5A2.25 2.25 0 014.5 5.25h15A2.25 2.25 0 0121.75 7.5v9A2.25 2.25 0 0119.5 18.75h-15A2.25 2.25 0 012.25 16.5v-9z'
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', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M6.75 12h3m4.5 0h3m-3-3v6'
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'
})
]
)
@@ -340,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(
@@ -435,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(
@@ -451,12 +513,15 @@ 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
? [
{
@@ -468,17 +533,26 @@ const userNavItems = computed(() => {
]
: []),
{ 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
? [
{
@@ -490,14 +564,34 @@ const personalNavItems = computed(() => {
]
: []),
{ 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 }]
@@ -510,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
})
@@ -601,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

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

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { buildModelMappingObject, getModelsByPlatform } from '../useModelWhitelist'
describe('useModelWhitelist', () => {
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'
})
})
})

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

@@ -95,6 +95,7 @@ const antigravityModels = [
'gemini-3.1-pro-high',
'gemini-3.1-pro-low',
'gemini-3.1-flash-image',
'gemini-3-pro-image',
// 其他
'gpt-oss-120b-medium',
'tab_flash_lite_preview'

View File

@@ -5,6 +5,7 @@ import { adminAPI } from '@/api/admin'
export interface OpenAITokenInfo {
access_token?: string
refresh_token?: string
client_id?: string
id_token?: string
token_type?: string
expires_in?: number
@@ -192,6 +193,10 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
scope: tokenInfo.scope
}
if (tokenInfo.client_id) {
creds.client_id = tokenInfo.client_id
}
// Include OpenAI specific IDs (required for forwarding)
if (tokenInfo.chatgpt_account_id) {
creds.chatgpt_account_id = tokenInfo.chatgpt_account_id

View File

@@ -270,6 +270,7 @@ export default {
redeemCodes: 'Redeem Codes',
ops: 'Ops',
promoCodes: 'Promo Codes',
dataManagement: 'Data Management',
settings: 'Settings',
myAccount: 'My Account',
lightMode: 'Light Mode',
@@ -311,6 +312,9 @@ export default {
passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.',
registrationFailed: 'Registration failed. Please try again.',
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
'This email domain is not allowed. Allowed domains: {suffixes}',
loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.',
@@ -325,6 +329,16 @@ export default {
sendingCode: 'Sending...',
clickToResend: 'Click to resend code',
resendCode: 'Resend verification code',
sendCodeDesc: "We'll send a verification code to",
codeSentSuccess: 'Verification code sent! Please check your inbox.',
verifying: 'Verifying...',
verifyAndCreate: 'Verify & Create Account',
resendCountdown: 'Resend code in {countdown}s',
backToRegistration: 'Back to registration',
sendCodeFailed: 'Failed to send verification code. Please try again.',
verifyFailed: 'Verification failed. Please try again.',
codeRequired: 'Verification code is required',
invalidCode: 'Please enter a valid 6-digit code',
promoCodeLabel: 'Promo Code',
promoCodePlaceholder: 'Enter promo code (optional)',
promoCodeValid: 'Valid! You will receive ${amount} bonus balance',
@@ -407,9 +421,12 @@ export default {
day: 'Day',
hour: 'Hour',
modelDistribution: 'Model Distribution',
groupDistribution: 'Group Usage Distribution',
tokenUsageTrend: 'Token Usage Trend',
noDataAvailable: 'No data available',
model: 'Model',
group: 'Group',
noGroup: 'No Group',
requests: 'Requests',
tokens: 'Tokens',
actual: 'Actual',
@@ -440,6 +457,9 @@ export default {
keys: {
title: 'API Keys',
description: 'Manage your API keys and access tokens',
searchPlaceholder: 'Search name or key...',
allGroups: 'All Groups',
allStatus: 'All Status',
createKey: 'Create API Key',
editKey: 'Edit API Key',
deleteKey: 'Delete API Key',
@@ -500,6 +520,7 @@ export default {
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
codexCli: 'Codex CLI',
codexCliWs: 'Codex CLI (WebSocket)',
opencode: 'OpenCode',
},
antigravity: {
@@ -555,6 +576,19 @@ export default {
resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.',
quotaResetSuccess: 'Quota reset successfully',
failedToResetQuota: 'Failed to reset quota',
rateLimitColumn: 'Rate Limit',
rateLimitSection: 'Rate Limit',
resetUsage: 'Reset',
rateLimit5h: '5-Hour Limit (USD)',
rateLimit1d: 'Daily Limit (USD)',
rateLimit7d: '7-Day Limit (USD)',
rateLimitHint: 'Set the maximum spending for this key within each time window. 0 = unlimited.',
rateLimitUsage: 'Rate Limit Usage',
resetRateLimitUsage: 'Reset Rate Limit Usage',
resetRateLimitTitle: 'Confirm Reset Rate Limit',
resetRateLimitConfirmMessage: 'Are you sure you want to reset the rate limit usage for key "{name}"? All time window usage will be reset to zero. This action cannot be undone.',
rateLimitResetSuccess: 'Rate limit usage reset successfully',
failedToResetRateLimit: 'Failed to reset rate limit usage',
expiration: 'Expiration',
expiresInDays: '{days} days',
extendDays: '+{days} days',
@@ -613,8 +647,10 @@ export default {
firstToken: 'First Token',
duration: 'Duration',
time: 'Time',
ws: 'WS',
stream: 'Stream',
sync: 'Sync',
unknown: 'Unknown',
in: 'In',
out: 'Out',
cacheRead: 'Read',
@@ -828,11 +864,12 @@ export default {
day: 'Day',
hour: 'Hour',
modelDistribution: 'Model Distribution',
groupDistribution: 'Group Distribution',
groupDistribution: 'Group Usage Distribution',
tokenUsageTrend: 'Token Usage Trend',
userUsageTrend: 'User Usage Trend (Top 12)',
model: 'Model',
group: 'Group',
noGroup: 'No Group',
requests: 'Requests',
tokens: 'Tokens',
actual: 'Actual',
@@ -842,6 +879,181 @@ export default {
failedToLoad: 'Failed to load dashboard statistics'
},
dataManagement: {
title: 'Data Management',
description: 'Manage data management agent status, object storage settings, and backup jobs in one place',
agent: {
title: 'Data Management Agent Status',
description: 'The system probes a fixed Unix socket and enables data management only when reachable.',
enabled: 'Data management agent is ready. Data management operations are available.',
disabled: 'Data management agent is unavailable. Only diagnostic information is available now.',
socketPath: 'Socket Path',
version: 'Version',
status: 'Status',
uptime: 'Uptime',
reasonLabel: 'Unavailable Reason',
reason: {
DATA_MANAGEMENT_AGENT_SOCKET_MISSING: 'Data management socket file is missing',
DATA_MANAGEMENT_AGENT_UNAVAILABLE: 'Data management agent is unreachable',
BACKUP_AGENT_SOCKET_MISSING: 'Backup socket file is missing',
BACKUP_AGENT_UNAVAILABLE: 'Backup agent is unreachable',
UNKNOWN: 'Unknown reason'
}
},
sections: {
config: {
title: 'Backup Configuration',
description: 'Configure backup source, retention policy, and S3 settings.'
},
s3: {
title: 'S3 Object Storage',
description: 'Configure and test uploads of backup artifacts to a standard S3-compatible storage.'
},
backup: {
title: 'Backup Operations',
description: 'Trigger PostgreSQL, Redis, and full backup jobs.'
},
history: {
title: 'Backup History',
description: 'Review backup job status, errors, and artifact metadata.'
}
},
form: {
sourceMode: 'Source Mode',
backupRoot: 'Backup Root',
activePostgresProfile: 'Active PostgreSQL Profile',
activeRedisProfile: 'Active Redis Profile',
activeS3Profile: 'Active S3 Profile',
retentionDays: 'Retention Days',
keepLast: 'Keep Last Jobs',
uploadToS3: 'Upload to S3',
useActivePostgresProfile: 'Use Active PostgreSQL Profile',
useActiveRedisProfile: 'Use Active Redis Profile',
useActiveS3Profile: 'Use Active Profile',
idempotencyKey: 'Idempotency Key (Optional)',
secretConfigured: 'Configured already, leave empty to keep unchanged',
source: {
profileID: 'Profile ID (Unique)',
profileName: 'Profile Name',
setActive: 'Set as active after creation'
},
postgres: {
title: 'PostgreSQL',
host: 'Host',
port: 'Port',
user: 'User',
password: 'Password',
database: 'Database',
sslMode: 'SSL Mode',
containerName: 'Container Name (docker_exec mode)'
},
redis: {
title: 'Redis',
addr: 'Address (host:port)',
username: 'Username',
password: 'Password',
db: 'Database Index',
containerName: 'Container Name (docker_exec mode)'
},
s3: {
enabled: 'Enable S3 Upload',
profileID: 'Profile ID (Unique)',
profileName: 'Profile Name',
endpoint: 'Endpoint (Optional)',
region: 'Region',
bucket: 'Bucket',
accessKeyID: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
prefix: 'Object Prefix',
forcePathStyle: 'Force Path Style',
useSSL: 'Use SSL',
setActive: 'Set as active after creation'
}
},
sourceProfiles: {
createTitle: 'Create Source Profile',
editTitle: 'Edit Source Profile',
empty: 'No source profiles yet, create one first',
deleteConfirm: 'Delete source profile {profileID}?',
columns: {
profile: 'Profile',
active: 'Active',
connection: 'Connection',
database: 'Database',
updatedAt: 'Updated At',
actions: 'Actions'
}
},
s3Profiles: {
createTitle: 'Create S3 Profile',
editTitle: 'Edit S3 Profile',
empty: 'No S3 profiles yet, create one first',
editHint: 'Click "Edit" to modify profile details in the right drawer.',
deleteConfirm: 'Delete S3 profile {profileID}?',
columns: {
profile: 'Profile',
active: 'Active',
storage: 'Storage',
updatedAt: 'Updated At',
actions: 'Actions'
}
},
history: {
total: '{count} jobs',
empty: 'No backup jobs yet',
columns: {
jobID: 'Job ID',
type: 'Type',
status: 'Status',
triggeredBy: 'Triggered By',
pgProfile: 'PostgreSQL Profile',
redisProfile: 'Redis Profile',
s3Profile: 'S3 Profile',
finishedAt: 'Finished At',
artifact: 'Artifact',
error: 'Error'
},
status: {
queued: 'Queued',
running: 'Running',
succeeded: 'Succeeded',
failed: 'Failed',
partial_succeeded: 'Partial Succeeded'
}
},
actions: {
refresh: 'Refresh Status',
disabledHint: 'Start datamanagementd first and ensure the socket is reachable.',
reloadConfig: 'Reload Config',
reloadSourceProfiles: 'Reload Source Profiles',
reloadProfiles: 'Reload Profiles',
newSourceProfile: 'New Source Profile',
saveConfig: 'Save Config',
configSaved: 'Configuration saved',
testS3: 'Test S3 Connection',
s3TestOK: 'S3 connection test succeeded',
s3TestFailed: 'S3 connection test failed',
newProfile: 'New Profile',
saveProfile: 'Save Profile',
activateProfile: 'Activate',
profileIDRequired: 'Profile ID is required',
profileNameRequired: 'Profile name is required',
profileSelectRequired: 'Select a profile to edit first',
profileCreated: 'S3 profile created',
profileSaved: 'S3 profile saved',
profileActivated: 'S3 profile activated',
profileDeleted: 'S3 profile deleted',
sourceProfileCreated: 'Source profile created',
sourceProfileSaved: 'Source profile saved',
sourceProfileActivated: 'Source profile activated',
sourceProfileDeleted: 'Source profile deleted',
createBackup: 'Create Backup Job',
jobCreated: 'Backup job created: {jobID} ({status})',
refreshJobs: 'Refresh Jobs',
loadMore: 'Load More'
}
},
// Users
users: {
title: 'User Management',
@@ -900,6 +1112,9 @@ export default {
noApiKeys: 'This user has no API keys',
group: 'Group',
none: 'None',
groupChangedSuccess: 'Group updated successfully',
groupChangedWithGrant: 'Group updated. User auto-granted access to "{group}"',
groupChangeFailed: 'Failed to update group',
noUsersYet: 'No users yet',
createFirstUser: 'Create your first user to get started.',
userCreated: 'User created successfully',
@@ -915,6 +1130,8 @@ export default {
failedToLoadApiKeys: 'Failed to load user API keys',
emailRequired: 'Please enter email',
concurrencyMin: 'Concurrency must be at least 1',
soraStorageQuota: 'Sora Storage Quota',
soraStorageQuotaHint: 'In GB, 0 means use group or system default quota',
amountRequired: 'Please enter a valid amount',
insufficientBalance: 'Insufficient balance',
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
@@ -1144,7 +1361,9 @@ export default {
image360: 'Image 360px ($)',
image540: 'Image 540px ($)',
video: 'Video (standard) ($)',
videoHd: 'Video (Pro-HD) ($)'
videoHd: 'Video (Pro-HD) ($)',
storageQuota: 'Storage Quota',
storageQuotaHint: 'In GB, set the Sora storage quota for users in this group. 0 means use system default'
},
claudeCode: {
title: 'Claude Code Client Restriction',
@@ -1389,6 +1608,10 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: 'Connect via Base URL + API Key',
soraApiKey: 'API Key / Upstream',
soraApiKeyHint: 'Connect to another Sub2API or compatible API',
soraBaseUrlRequired: 'Sora API Key account requires a Base URL',
soraBaseUrlInvalidScheme: 'Base URL must start with http:// or https://',
upstream: 'Upstream',
upstreamDesc: 'Connect via Base URL + API Key'
},
@@ -1437,7 +1660,19 @@ export default {
sessions: {
full: 'Active sessions full, new sessions must wait (idle timeout: {idle} min)',
normal: 'Active sessions normal (idle timeout: {idle} min)'
}
},
rpm: {
full: 'RPM limit reached',
warning: 'RPM approaching limit',
normal: 'RPM normal',
tieredNormal: 'RPM limit (Tiered) - Normal',
tieredWarning: 'RPM limit (Tiered) - Approaching limit',
tieredStickyOnly: 'RPM limit (Tiered) - Sticky only | Buffer: {buffer}',
tieredBlocked: 'RPM limit (Tiered) - Blocked | Buffer: {buffer}',
stickyExemptNormal: 'RPM limit (Sticky Exempt) - Normal',
stickyExemptWarning: 'RPM limit (Sticky Exempt) - Approaching limit',
stickyExemptOver: 'RPM limit (Sticky Exempt) - Over limit, sticky only'
},
},
tempUnschedulable: {
title: 'Temp Unschedulable',
@@ -1554,6 +1789,24 @@ export default {
oauthPassthrough: 'Auto passthrough (auth only)',
oauthPassthroughDesc:
'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.',
responsesWebsocketsV2: 'Responses WebSocket v2',
responsesWebsocketsV2Desc:
'Disabled by default. Enable to allow responses_websockets_v2 capability (still gated by global and account-type switches).',
wsMode: 'WS mode',
wsModeDesc: 'Only applies to the current OpenAI account type.',
wsModeOff: 'Off (off)',
wsModeShared: 'Shared (shared)',
wsModeDedicated: 'Dedicated (dedicated)',
wsModeConcurrencyHint:
'When WS mode is enabled, account concurrency becomes the WS connection pool limit for this account.',
oauthResponsesWebsocketsV2: 'OAuth WebSocket Mode',
oauthResponsesWebsocketsV2Desc:
'Only applies to OpenAI OAuth. This account can use OpenAI WebSocket Mode only when enabled.',
apiKeyResponsesWebsocketsV2: 'API Key WebSocket Mode',
apiKeyResponsesWebsocketsV2Desc:
'Only applies to OpenAI API Key. This account can use OpenAI WebSocket Mode only when enabled.',
responsesWebsocketsV2PassthroughHint:
'Automatic passthrough is currently enabled: it only affects HTTP passthrough and does not disable WS mode.',
codexCLIOnly: 'Codex official clients only',
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
@@ -1634,6 +1887,27 @@ export default {
idleTimeoutPlaceholder: '5',
idleTimeoutHint: 'Sessions will be released after idle timeout'
},
rpmLimit: {
label: 'RPM Limit',
hint: 'Limit requests per minute to protect upstream accounts',
baseRpm: 'Base RPM',
baseRpmPlaceholder: '15',
baseRpmHint: 'Max requests per minute, 0 or empty means no limit',
strategy: 'RPM Strategy',
strategyTiered: 'Tiered Model',
strategyStickyExempt: 'Sticky Exempt',
strategyTieredHint: 'Green → Yellow → Sticky only → Blocked, progressive throttling',
strategyStickyExemptHint: 'Only sticky sessions allowed when over limit',
strategyHint: 'Tiered: gradually restrict when exceeded; Sticky Exempt: existing sessions unrestricted',
stickyBuffer: 'Sticky Buffer',
stickyBufferPlaceholder: 'Default: 20% of base RPM',
stickyBufferHint: 'Extra requests allowed for sticky sessions after exceeding base RPM. Leave empty to use default (20% of base RPM, min 1)',
userMsgQueue: 'User Message Rate Control',
userMsgQueueHint: 'Rate-limit user messages to avoid triggering upstream RPM limits',
umqModeOff: 'Off',
umqModeThrottle: 'Throttle',
umqModeSerialize: 'Serialize',
},
tlsFingerprint: {
label: 'TLS Fingerprint Simulation',
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
@@ -1763,6 +2037,15 @@ export default {
sessionTokenAuth: 'Manual ST Input',
sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
sessionTokenRawLabel: 'Raw Input',
sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...',
sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.',
openSessionUrl: 'Open Fetch URL',
copySessionUrl: 'Copy URL',
sessionUrlHint: 'This URL usually returns AT. If sessionToken is absent, copy __Secure-next-auth.session-token from browser cookies as ST.',
parsedSessionTokensLabel: 'Parsed ST',
parsedSessionTokensEmpty: 'No ST parsed. Please check your input.',
parsedAccessTokensLabel: 'Parsed AT',
validating: 'Validating...',
validateAndCreate: 'Validate & Create Account',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
@@ -2013,6 +2296,7 @@ export default {
selectTestModel: 'Select Test Model',
testModel: 'Test model',
testPrompt: 'Prompt: "hi"',
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
soraTestTarget: 'Target: Sora account capability',
soraTestMode: 'Mode: Connectivity + Capability checks',
@@ -2103,6 +2387,8 @@ export default {
dataExportConfirm: 'Confirm Export',
dataExported: 'Data exported successfully',
dataExportFailed: 'Failed to export data',
copyProxyUrl: 'Copy Proxy URL',
urlCopied: 'Proxy URL copied',
searchProxies: 'Search proxies...',
allProtocols: 'All Protocols',
allStatus: 'All Status',
@@ -2116,6 +2402,7 @@ export default {
name: 'Name',
protocol: 'Protocol',
address: 'Address',
auth: 'Auth',
location: 'Location',
status: 'Status',
accounts: 'Accounts',
@@ -3255,6 +3542,15 @@ export default {
settings: {
title: 'System Settings',
description: 'Manage registration, email verification, default values, and SMTP settings',
tabs: {
general: 'General',
security: 'Security',
users: 'Users',
gateway: 'Gateway',
email: 'Email',
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
registration: {
title: 'Registration Settings',
description: 'Control user registration and verification',
@@ -3262,6 +3558,11 @@ export default {
enableRegistrationHint: 'Allow new users to register',
emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations',
emailSuffixWhitelist: 'Email Domain Whitelist',
emailSuffixWhitelistHint:
"Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com)",
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: 'Leave empty for no restriction',
promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration',
@@ -3310,7 +3611,29 @@ export default {
defaultBalance: 'Default Balance',
defaultBalanceHint: 'Initial balance for new users',
defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users'
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
defaultSubscriptions: 'Default Subscriptions',
defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered',
addDefaultSubscription: 'Add Default Subscription',
defaultSubscriptionsEmpty: 'No default subscriptions configured.',
defaultSubscriptionsDuplicate:
'Duplicate subscription group: {groupId}. Each group can only appear once.',
subscriptionGroup: 'Subscription Group',
subscriptionValidityDays: 'Validity (days)'
},
claudeCode: {
title: 'Claude Code Settings',
description: 'Control Claude Code client access requirements',
minVersion: 'Minimum Version',
minVersionPlaceholder: 'e.g. 2.1.63',
minVersionHint:
'Reject Claude Code clients below this version (semver format). Leave empty to disable version check.'
},
scheduling: {
title: 'Gateway Scheduling Settings',
description: 'Control API Key scheduling behavior',
allowUngroupedKey: 'Allow Ungrouped Key Scheduling',
allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.'
},
site: {
title: 'Site Settings',
@@ -3358,6 +3681,33 @@ export default {
integrationDoc: 'Payment Integration Docs',
integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples'
},
soraClient: {
title: 'Sora Client',
description: 'Control whether to show the Sora client entry in the sidebar',
enabled: 'Enable Sora Client',
enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features'
},
customMenu: {
title: 'Custom Menu Pages',
description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.',
itemLabel: 'Menu Item #{n}',
name: 'Menu Name',
namePlaceholder: 'e.g. Help Center',
url: 'Page URL',
urlPlaceholder: 'https://example.com/page',
iconSvg: 'SVG Icon',
iconSvgPlaceholder: '<svg>...</svg>',
iconPreview: 'Icon Preview',
uploadSvg: 'Upload SVG',
removeSvg: 'Remove',
visibility: 'Visible To',
visibilityUser: 'Regular Users',
visibilityAdmin: 'Administrators',
add: 'Add Menu Item',
remove: 'Remove',
moveUp: 'Move Up',
moveDown: 'Move Down',
},
smtp: {
title: 'SMTP Settings',
description: 'Configure email sending for verification codes',
@@ -3429,6 +3779,60 @@ export default {
securityWarning: 'Warning: This key provides full admin access. Keep it secure.',
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora S3 Storage',
description: 'Manage multiple Sora S3 endpoints and switch the active profile',
newProfile: 'New Profile',
reloadProfiles: 'Reload Profiles',
empty: 'No Sora S3 profiles yet, create one first',
createTitle: 'Create Sora S3 Profile',
editTitle: 'Edit Sora S3 Profile',
profileID: 'Profile ID',
profileName: 'Profile Name',
setActive: 'Set as active after creation',
saveProfile: 'Save Profile',
activateProfile: 'Activate',
profileCreated: 'Sora S3 profile created',
profileSaved: 'Sora S3 profile saved',
profileDeleted: 'Sora S3 profile deleted',
profileActivated: 'Sora S3 active profile switched',
profileIDRequired: 'Profile ID is required',
profileNameRequired: 'Profile name is required',
profileSelectRequired: 'Please select a profile first',
endpointRequired: 'S3 endpoint is required when enabled',
bucketRequired: 'Bucket is required when enabled',
accessKeyRequired: 'Access Key ID is required when enabled',
deleteConfirm: 'Delete Sora S3 profile {profileID}?',
columns: {
profile: 'Profile',
active: 'Active',
endpoint: 'Endpoint',
bucket: 'Bucket',
quota: 'Default Quota',
updatedAt: 'Updated At',
actions: 'Actions'
},
enabled: 'Enable S3 Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded to S3 storage',
endpoint: 'S3 Endpoint',
region: 'Region',
bucket: 'Bucket',
prefix: 'Object Prefix',
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
secretConfigured: '(Configured, leave blank to keep)',
cdnUrl: 'CDN URL',
cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL instead of presigned URLs',
forcePathStyle: 'Force Path Style',
defaultQuota: 'Default Storage Quota',
defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited',
testConnection: 'Test Connection',
testing: 'Testing...',
testSuccess: 'S3 connection test successful',
testFailed: 'S3 connection test failed',
saved: 'Sora S3 settings saved successfully',
saveFailed: 'Failed to save Sora S3 settings'
},
streamTimeout: {
title: 'Stream Timeout Handling',
description: 'Configure account handling strategy when upstream response times out',
@@ -3592,6 +3996,16 @@ export default {
'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.'
},
// Custom Page (iframe embed)
customPage: {
title: 'Custom Page',
openInNewTab: 'Open in new tab',
notFoundTitle: 'Page not found',
notFoundDesc: 'This custom page does not exist or has been removed.',
notConfiguredTitle: 'Page URL not configured',
notConfiguredDesc: 'The URL for this custom page has not been properly configured.',
},
// Announcements Page
announcements: {
title: 'Announcements',
@@ -3787,5 +4201,93 @@ export default {
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click to confirm and create your API key.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important:</b><ul style="margin: 8px 0 0 16px;"><li>Copy the key (sk-xxx) immediately after creation</li><li>Key is only shown once, need to regenerate if lost</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 How to Use:</b><br/>Configure the key in any OpenAI-compatible client (like ChatBox, OpenCat, etc.) and start using!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
}
}
},
// Sora Studio
sora: {
title: 'Sora Studio',
description: 'Generate videos and images with Sora AI',
notEnabled: 'Feature Not Available',
notEnabledDesc: 'The Sora Studio feature has not been enabled by the administrator. Please contact your admin.',
tabGenerate: 'Generate',
tabLibrary: 'Library',
noActiveGenerations: 'No active generations',
startGenerating: 'Enter a prompt below to start creating',
storage: 'Storage',
promptPlaceholder: 'Describe what you want to create...',
generate: 'Generate',
generating: 'Generating...',
selectModel: 'Select Model',
statusPending: 'Pending',
statusGenerating: 'Generating',
statusCompleted: 'Completed',
statusFailed: 'Failed',
statusCancelled: 'Cancelled',
cancel: 'Cancel',
delete: 'Delete',
save: 'Save to Cloud',
saved: 'Saved',
retry: 'Retry',
download: 'Download',
justNow: 'Just now',
minutesAgo: '{n} min ago',
hoursAgo: '{n} hr ago',
noSavedWorks: 'No saved works',
saveWorksHint: 'Save your completed generations to the library',
filterAll: 'All',
filterVideo: 'Video',
filterImage: 'Image',
confirmDelete: 'Are you sure you want to delete this work?',
loading: 'Loading...',
loadMore: 'Load More',
noStorageWarningTitle: 'No Storage Configured',
noStorageWarningDesc: 'Generated content is only available via temporary upstream links that expire in ~15 minutes. Consider configuring S3 storage.',
mediaTypeVideo: 'Video',
mediaTypeImage: 'Image',
notificationCompleted: 'Generation Complete',
notificationFailed: 'Generation Failed',
notificationCompletedBody: 'Your {model} task has completed',
notificationFailedBody: 'Your {model} task has failed',
upstreamExpiresSoon: 'Expiring soon',
upstreamExpired: 'Link expired',
upstreamCountdown: '{time} remaining',
previewTitle: 'Preview',
closePreview: 'Close',
beforeUnloadWarning: 'You have unsaved generated content. Are you sure you want to leave?',
downloadTitle: 'Download Generated Content',
downloadExpirationWarning: 'This link expires in approximately 15 minutes. Please download and save promptly.',
downloadNow: 'Download Now',
referenceImage: 'Reference Image',
removeImage: 'Remove',
imageTooLarge: 'Image size cannot exceed 20MB',
// Sora dark theme additions
welcomeTitle: 'Turn your imagination into video',
welcomeSubtitle: 'Enter a description and Sora will create realistic videos or images for you. Try the examples below to get started.',
queueTasks: 'tasks',
queueWaiting: 'Queued',
waiting: 'Waiting',
waited: 'Waited',
errorCategory: 'Content Policy Violation',
savedToCloud: 'Saved to Cloud',
downloadLocal: 'Download',
canDownload: 'to download',
regenrate: 'Regenerate',
creatorPlaceholder: 'Describe the video or image you want to create...',
videoModels: 'Video Models',
imageModels: 'Image Models',
noStorageConfigured: 'No Storage',
selectCredential: 'Select Credential',
apiKeys: 'API Keys',
subscriptions: 'Subscriptions',
subscription: 'Subscription',
noCredentialHint: 'Please create an API Key or contact admin for subscription',
uploadReference: 'Upload reference image',
generatingCount: 'Generating {current}/{max}',
noStorageToastMessage: 'Cloud storage is not configured. Please use "Download" to save files after generation, otherwise they will be lost.',
galleryCount: '{count} works',
galleryEmptyTitle: 'No works yet',
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
startCreating: 'Start Creating',
yesterday: 'Yesterday'
}
}

View File

@@ -312,6 +312,8 @@ export default {
passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。',
registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。',
@@ -326,6 +328,16 @@ export default {
sendingCode: '发送中...',
clickToResend: '点击重新发送验证码',
resendCode: '重新发送验证码',
sendCodeDesc: '我们将发送验证码到',
codeSentSuccess: '验证码已发送!请查收您的邮箱。',
verifying: '验证中...',
verifyAndCreate: '验证并创建账户',
resendCountdown: '{countdown}秒后可重新发送',
backToRegistration: '返回注册',
sendCodeFailed: '发送验证码失败,请重试。',
verifyFailed: '验证失败,请重试。',
codeRequired: '请输入验证码',
invalidCode: '请输入有效的6位验证码',
promoCodeLabel: '优惠码',
promoCodePlaceholder: '输入优惠码(可选)',
promoCodeValid: '有效!注册后将获得 ${amount} 赠送余额',
@@ -414,6 +426,7 @@ export default {
noDataAvailable: '暂无数据',
model: '模型',
group: '分组',
noGroup: '无分组',
requests: '请求',
tokens: 'Token',
actual: '实际',
@@ -444,6 +457,9 @@ export default {
keys: {
title: 'API 密钥',
description: '管理您的 API 密钥和访问令牌',
searchPlaceholder: '搜索名称或Key...',
allGroups: '全部分组',
allStatus: '全部状态',
createKey: '创建密钥',
editKey: '编辑密钥',
deleteKey: '删除密钥',
@@ -565,6 +581,19 @@ export default {
resetQuotaConfirmMessage: '确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。',
quotaResetSuccess: '额度重置成功',
failedToResetQuota: '重置额度失败',
rateLimitColumn: '速率限制',
rateLimitSection: '速率限制',
resetUsage: '重置',
rateLimit5h: '5小时限额 (USD)',
rateLimit1d: '日限额 (USD)',
rateLimit7d: '7天限额 (USD)',
rateLimitHint: '设置此密钥在指定时间窗口内的最大消费额。0 = 无限制。',
rateLimitUsage: '速率限制用量',
resetRateLimitUsage: '重置速率限制用量',
resetRateLimitTitle: '确认重置速率限制',
resetRateLimitConfirmMessage: '确定要重置密钥 "{name}" 的速率限制用量吗?所有时间窗口的已用额度将归零。此操作不可撤销。',
rateLimitResetSuccess: '速率限制已重置',
failedToResetRateLimit: '重置速率限制失败',
expiration: '密钥有效期',
expiresInDays: '{days} 天',
extendDays: '+{days} 天',
@@ -853,6 +882,7 @@ export default {
noDataAvailable: '暂无数据',
model: '模型',
group: '分组',
noGroup: '无分组',
requests: '请求',
tokens: 'Token',
cache: '缓存',
@@ -2005,7 +2035,12 @@ export default {
strategyHint: '三区模型: 超限后逐步限制; 粘性豁免: 已有会话不受限',
stickyBuffer: '粘性缓冲区',
stickyBufferPlaceholder: '默认: base RPM 的 20%',
stickyBufferHint: '超过 base RPM 后粘性会话额外允许的请求数。为空则使用默认值base RPM 的 20%,最小为 1'
stickyBufferHint: '超过 base RPM 后粘性会话额外允许的请求数。为空则使用默认值base RPM 的 20%,最小为 1',
userMsgQueue: '用户消息限速',
userMsgQueueHint: '对用户消息施加发送限制,避免触发上游 RPM 限制',
umqModeOff: '关闭',
umqModeThrottle: '软性限速',
umqModeSerialize: '串行队列',
},
tlsFingerprint: {
label: 'TLS 指纹模拟',
@@ -2457,6 +2492,7 @@ export default {
name: '名称',
protocol: '协议',
address: '地址',
auth: '认证',
location: '地理位置',
status: '状态',
accounts: '账号数',
@@ -2484,6 +2520,8 @@ export default {
allStatuses: '全部状态'
},
// Additional keys used in ProxiesView
copyProxyUrl: '复制代理 URL',
urlCopied: '代理 URL 已复制',
allProtocols: '全部协议',
allStatus: '全部状态',
searchProxies: '搜索代理...',
@@ -3665,6 +3703,15 @@ export default {
settings: {
title: '系统设置',
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
tabs: {
general: '通用设置',
security: '安全与认证',
users: '用户默认值',
gateway: '网关服务',
email: '邮件设置',
},
emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
registration: {
title: '注册设置',
description: '控制用户注册和验证',
@@ -3672,6 +3719,11 @@ export default {
enableRegistrationHint: '允许新用户注册',
emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱',
emailSuffixWhitelist: '邮箱域名白名单',
emailSuffixWhitelistHint:
"仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com",
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: '留空则不限制',
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册',
@@ -3720,7 +3772,27 @@ export default {
defaultBalance: '默认余额',
defaultBalanceHint: '新用户的初始余额',
defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数'
defaultConcurrencyHint: '新用户的最大并发请求数',
defaultSubscriptions: '默认订阅列表',
defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅',
addDefaultSubscription: '添加默认订阅',
defaultSubscriptionsEmpty: '未配置默认订阅。新用户不会自动获得订阅套餐。',
defaultSubscriptionsDuplicate: '默认订阅存在重复分组:{groupId}。每个分组只能出现一次。',
subscriptionGroup: '订阅分组',
subscriptionValidityDays: '有效期(天)'
},
claudeCode: {
title: 'Claude Code 设置',
description: '控制 Claude Code 客户端访问要求',
minVersion: '最低版本号',
minVersionPlaceholder: '例如 2.1.63',
minVersionHint: '拒绝低于此版本的 Claude Code 客户端请求semver 格式)。留空则不检查版本。'
},
scheduling: {
title: '网关调度设置',
description: '控制 API Key 的调度行为',
allowUngroupedKey: '允许未分组 Key 调度',
allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403。建议保持关闭以确保所有 Key 都归属明确的分组。'
},
site: {
title: '站点设置',
@@ -3776,6 +3848,27 @@ export default {
enabled: '启用 Sora 客户端',
enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能'
},
customMenu: {
title: '自定义菜单页面',
description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。',
itemLabel: '菜单项 #{n}',
name: '菜单名称',
namePlaceholder: '如:帮助中心',
url: '页面 URL',
urlPlaceholder: 'https://example.com/page',
iconSvg: 'SVG 图标',
iconSvgPlaceholder: '<svg>...</svg>',
iconPreview: '图标预览',
uploadSvg: '上传 SVG',
removeSvg: '清除',
visibility: '可见角色',
visibilityUser: '普通用户',
visibilityAdmin: '管理员',
add: '添加菜单项',
remove: '删除',
moveUp: '上移',
moveDown: '下移',
},
smtp: {
title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务',
@@ -4062,6 +4155,16 @@ export default {
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
},
// Custom Page (iframe embed)
customPage: {
title: '自定义页面',
openInNewTab: '新窗口打开',
notFoundTitle: '页面不存在',
notFoundDesc: '该自定义页面不存在或已被删除。',
notConfiguredTitle: '页面链接未配置',
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
},
// Announcements Page
announcements: {
title: '公告',

View File

@@ -6,7 +6,18 @@ import i18n, { initI18n } from './i18n'
import { useAppStore } from '@/stores/app'
import './style.css'
function initThemeClass() {
const savedTheme = localStorage.getItem('theme')
const shouldUseDark =
savedTheme === 'dark' ||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', shouldUseDark)
}
async function bootstrap() {
// Apply theme class globally before app mount to keep all routes consistent.
initThemeClass()
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)

View File

@@ -6,6 +6,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { useAdminSettingsStore } from '@/stores/adminSettings'
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
import { resolveDocumentTitle } from './title'
@@ -191,6 +192,29 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'purchase.description'
}
},
{
path: '/sora',
name: 'Sora',
component: () => import('@/views/user/SoraView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Sora',
titleKey: 'sora.title',
descriptionKey: 'sora.description'
}
},
{
path: '/custom/:id',
name: 'CustomPage',
component: () => import('@/views/user/CustomPageView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Custom Page',
titleKey: 'customPage.title',
}
},
// ==================== Admin Routes ====================
{
@@ -317,6 +341,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.promo.description'
}
},
{
path: '/admin/data-management',
name: 'AdminDataManagement',
component: () => import('@/views/admin/DataManagementView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Data Management',
titleKey: 'admin.dataManagement.title',
descriptionKey: 'admin.dataManagement.description'
}
},
{
path: '/admin/settings',
name: 'AdminSettings',
@@ -393,7 +429,22 @@ router.beforeEach((to, _from, next) => {
// Set page title
const appStore = useAppStore()
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string)
// For custom pages, use menu item label as document title
if (to.name === 'CustomPage') {
const id = to.params.id as string
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
const adminSettingsStore = useAdminSettingsStore()
const menuItem = publicItems.find((item) => item.id === id)
?? (authStore.isAdmin ? adminSettingsStore.customMenuItems.find((item) => item.id === id) : undefined)
if (menuItem?.label) {
const siteName = appStore.siteName || 'Sub2API'
document.title = `${menuItem.label} - ${siteName}`
} else {
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string)
}
} else {
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string)
}
// Check if route requires authentication
const requiresAuth = to.meta.requiresAuth !== false // Default to true

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { adminAPI } from '@/api'
import type { CustomMenuItem } from '@/types'
export const useAdminSettingsStore = defineStore('adminSettings', () => {
const loaded = ref(false)
@@ -47,6 +48,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true))
const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true))
const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto'))
const customMenuItems = ref<CustomMenuItem[]>([])
async function fetch(force = false): Promise<void> {
if (loaded.value && !force) return
@@ -64,6 +66,8 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
opsQueryModeDefault.value = settings.ops_query_mode_default || 'auto'
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : []
loaded.value = true
} catch (err) {
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
@@ -122,6 +126,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
opsMonitoringEnabled,
opsRealtimeMonitoringEnabled,
opsQueryModeDefault,
customMenuItems,
fetch,
setOpsMonitoringEnabledLocal,
setOpsRealtimeMonitoringEnabledLocal,

View File

@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => {
return {
registration_enabled: false,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
password_reset_enabled: false,
invitation_code_enabled: false,
@@ -327,7 +328,9 @@ export const useAppStore = defineStore('app', () => {
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
custom_menu_items: [],
linuxdo_oauth_enabled: false,
sora_client_enabled: false,
version: siteVersion.value
}
}

View File

@@ -45,6 +45,9 @@ export interface AdminUser extends User {
group_rates?: Record<number, number>
// 当前并发数(仅管理员列表接口返回)
current_concurrency?: number
// Sora 存储配额(字节)
sora_storage_quota_bytes: number
sora_storage_used_bytes: number
}
export interface LoginRequest {
@@ -72,9 +75,19 @@ export interface SendVerifyCodeResponse {
countdown: number
}
export interface CustomMenuItem {
id: string
label: string
icon_svg: string
url: string
visibility: 'user' | 'admin'
sort_order: number
}
export interface PublicSettings {
registration_enabled: boolean
email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
@@ -90,7 +103,9 @@ export interface PublicSettings {
hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
custom_menu_items: CustomMenuItem[]
linuxdo_oauth_enabled: boolean
sora_client_enabled: boolean
version: string
}
@@ -363,6 +378,8 @@ export interface Group {
sora_image_price_540: number | null
sora_video_price_per_request: number | null
sora_video_price_per_request_hd: number | null
// Sora 存储配额(字节)
sora_storage_quota_bytes: number
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
@@ -407,6 +424,15 @@ export interface ApiKey {
created_at: string
updated_at: string
group?: Group
rate_limit_5h: number
rate_limit_1d: number
rate_limit_7d: number
usage_5h: number
usage_1d: number
usage_7d: number
window_5h_start: string | null
window_1d_start: string | null
window_7d_start: string | null
}
export interface CreateApiKeyRequest {
@@ -417,6 +443,9 @@ export interface CreateApiKeyRequest {
ip_blacklist?: string[]
quota?: number // Quota limit in USD (0 = unlimited)
expires_in_days?: number // Days until expiry (null = never expires)
rate_limit_5h?: number
rate_limit_1d?: number
rate_limit_7d?: number
}
export interface UpdateApiKeyRequest {
@@ -428,6 +457,10 @@ export interface UpdateApiKeyRequest {
quota?: number // Quota limit in USD (null = no change, 0 = unlimited)
expires_at?: string | null // Expiration time (null = no change)
reset_quota?: boolean // Reset quota_used to 0
rate_limit_5h?: number
rate_limit_1d?: number
rate_limit_7d?: number
reset_rate_limit_usage?: boolean
}
export interface CreateGroupRequest {
@@ -447,6 +480,7 @@ export interface CreateGroupRequest {
sora_image_price_540?: number | null
sora_video_price_per_request?: number | null
sora_video_price_per_request_hd?: number | null
sora_storage_quota_bytes?: number
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
@@ -475,6 +509,7 @@ export interface UpdateGroupRequest {
sora_image_price_540?: number | null
sora_video_price_per_request?: number | null
sora_video_price_per_request_hd?: number | null
sora_storage_quota_bytes?: number
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
@@ -657,6 +692,12 @@ export interface Account {
max_sessions?: number | null
session_idle_timeout_minutes?: number | null
// RPM 限制(仅 Anthropic OAuth/SetupToken 账号有效)
base_rpm?: number | null
rpm_strategy?: string | null
rpm_sticky_buffer?: number | null
user_msg_queue_mode?: string | null // "serialize" | "throttle" | null
// TLS指纹伪装仅 Anthropic OAuth/SetupToken 账号有效)
enable_tls_fingerprint?: boolean | null
@@ -671,6 +712,7 @@ export interface Account {
// 运行时状态(仅当启用对应限制时返回)
current_window_cost?: number | null // 当前窗口费用
active_sessions?: number | null // 当前活跃会话数
current_rpm?: number | null // 当前分钟 RPM 计数
}
// Account Usage types
@@ -863,6 +905,7 @@ export interface AdminDataImportResult {
// ==================== Usage & Redeem Types ====================
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2'
export interface UsageLog {
id: number
@@ -892,7 +935,9 @@ export interface UsageLog {
rate_multiplier: number
billing_type: number
request_type?: UsageRequestType
stream: boolean
openai_ws_mode?: boolean
duration_ms: number
first_token_ms: number | null
@@ -938,6 +983,7 @@ export interface UsageCleanupFilters {
account_id?: number
group_id?: number
model?: string | null
request_type?: UsageRequestType | null
stream?: boolean | null
billing_type?: number | null
}
@@ -1191,6 +1237,7 @@ export interface UsageQueryParams {
account_id?: number
group_id?: number
model?: string
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
start_date?: string

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { buildAuthErrorMessage } from '@/utils/authError'
describe('buildAuthErrorMessage', () => {
it('prefers response detail message when available', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
detail: 'detailed message',
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('detailed message')
})
it('falls back to response message when detail is unavailable', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('plain message')
})
it('falls back to error.message when response payload is unavailable', () => {
const message = buildAuthErrorMessage(
{
message: 'error message'
},
{ fallback: 'fallback' }
)
expect(message).toBe('error message')
})
it('uses fallback when no message can be extracted', () => {
expect(buildAuthErrorMessage({}, { fallback: 'fallback' })).toBe('fallback')
})
})

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import {
OPENAI_WS_MODE_DEDICATED,
OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED,
isOpenAIWSModeEnabled,
normalizeOpenAIWSMode,
openAIWSModeFromEnabled,
resolveOpenAIWSModeFromExtra
} from '@/utils/openaiWsMode'
describe('openaiWsMode utils', () => {
it('normalizes mode values', () => {
expect(normalizeOpenAIWSMode('off')).toBe(OPENAI_WS_MODE_OFF)
expect(normalizeOpenAIWSMode(' Shared ')).toBe(OPENAI_WS_MODE_SHARED)
expect(normalizeOpenAIWSMode('DEDICATED')).toBe(OPENAI_WS_MODE_DEDICATED)
expect(normalizeOpenAIWSMode('invalid')).toBeNull()
})
it('maps legacy enabled flag to mode', () => {
expect(openAIWSModeFromEnabled(true)).toBe(OPENAI_WS_MODE_SHARED)
expect(openAIWSModeFromEnabled(false)).toBe(OPENAI_WS_MODE_OFF)
expect(openAIWSModeFromEnabled('true')).toBeNull()
})
it('resolves by mode key first, then enabled, then fallback enabled keys', () => {
const extra = {
openai_oauth_responses_websockets_v2_mode: 'dedicated',
openai_oauth_responses_websockets_v2_enabled: false,
responses_websockets_v2_enabled: false
}
const mode = resolveOpenAIWSModeFromExtra(extra, {
modeKey: 'openai_oauth_responses_websockets_v2_mode',
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled']
})
expect(mode).toBe(OPENAI_WS_MODE_DEDICATED)
})
it('falls back to default when nothing is present', () => {
const mode = resolveOpenAIWSModeFromExtra({}, {
modeKey: 'openai_apikey_responses_websockets_v2_mode',
enabledKey: 'openai_apikey_responses_websockets_v2_enabled',
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
defaultMode: OPENAI_WS_MODE_OFF
})
expect(mode).toBe(OPENAI_WS_MODE_OFF)
})
it('treats off as disabled and shared/dedicated as enabled', () => {
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_OFF)).toBe(false)
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_SHARED)).toBe(true)
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_DEDICATED)).toBe(true)
})
})

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
isRegistrationEmailSuffixAllowed,
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
normalizeRegistrationEmailSuffixWhitelist,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
describe('registrationEmailPolicy utils', () => {
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
})
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
expect(
normalizeRegistrationEmailSuffixDomains([
'@example.com',
'Example.com',
'',
'-invalid.com',
'foo..bar.com',
' @foo.bar ',
'@foo.bar'
])
).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
const input = '\n @example.com,example.com@foo.bar\t@FOO.bar '
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
const input = '@exa!mple.com, @foo.bar, @bad#token.com, @ok-domain.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'ok-domain.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput returns empty list for blank input', () => {
expect(parseRegistrationEmailSuffixWhitelistInput(' \n \n')).toEqual([])
})
it('normalizeRegistrationEmailSuffixWhitelist returns canonical @domain list', () => {
expect(
normalizeRegistrationEmailSuffixWhitelist([
'@Example.com',
'foo.bar',
'',
'-invalid.com',
' @foo.bar '
])
).toEqual(['@example.com', '@foo.bar'])
})
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
})
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', [])).toBe(true)
})
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
})
})

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
describe('parseSoraRawTokens', () => {
it('parses sessionToken and accessToken from JSON payload', () => {
const payload = JSON.stringify({
user: { id: 'u1' },
accessToken: 'at-json-1',
sessionToken: 'st-json-1'
})
const result = parseSoraRawTokens(payload)
expect(result.sessionTokens).toEqual(['st-json-1'])
expect(result.accessTokens).toEqual(['at-json-1'])
})
it('supports plain session tokens (one per line)', () => {
const result = parseSoraRawTokens('st-1\nst-2')
expect(result.sessionTokens).toEqual(['st-1', 'st-2'])
expect(result.accessTokens).toEqual([])
})
it('supports non-standard object snippets via regex', () => {
const raw = "sessionToken: 'st-snippet', access_token: \"at-snippet\""
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['st-snippet'])
expect(result.accessTokens).toEqual(['at-snippet'])
})
it('keeps unique tokens and extracts JWT-like plain line as AT too', () => {
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature'
const raw = `st-dup\nst-dup\n${jwt}\n${JSON.stringify({ sessionToken: 'st-json', accessToken: jwt })}`
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['st-json', 'st-dup'])
expect(result.accessTokens).toEqual([jwt])
})
it('parses session token from Set-Cookie line and strips cookie attributes', () => {
const raw =
'__Secure-next-auth.session-token.0=st-cookie-part-0; Domain=.chatgpt.com; Path=/; Expires=Thu, 28 May 2026 11:43:36 GMT; HttpOnly; Secure; SameSite=Lax'
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['st-cookie-part-0'])
expect(result.accessTokens).toEqual([])
})
it('merges chunked session-token cookies by numeric suffix order', () => {
const raw = [
'Set-Cookie: __Secure-next-auth.session-token.1=part-1; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.0=part-0; Path=/; HttpOnly'
].join('\n')
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['part-0part-1'])
expect(result.accessTokens).toEqual([])
})
it('prefers latest duplicate chunk values when multiple cookie groups exist', () => {
const raw = [
'Set-Cookie: __Secure-next-auth.session-token.0=old-0; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.1=old-1; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.0=new-0; Path=/; HttpOnly',
'Set-Cookie: __Secure-next-auth.session-token.1=new-1; Path=/; HttpOnly'
].join('\n')
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['new-0new-1'])
expect(result.accessTokens).toEqual([])
})
it('uses latest complete chunk group and ignores incomplete latest group', () => {
const raw = [
'set-cookie',
'__Secure-next-auth.session-token.0=ok-0; Domain=.chatgpt.com; Path=/',
'set-cookie',
'__Secure-next-auth.session-token.1=ok-1; Domain=.chatgpt.com; Path=/',
'set-cookie',
'__Secure-next-auth.session-token.0=partial-0; Domain=.chatgpt.com; Path=/'
].join('\n')
const result = parseSoraRawTokens(raw)
expect(result.sessionTokens).toEqual(['ok-0ok-1'])
expect(result.accessTokens).toEqual([])
})
})

View File

@@ -0,0 +1,25 @@
interface APIErrorLike {
message?: string
response?: {
data?: {
detail?: string
message?: string
}
}
}
function extractErrorMessage(error: unknown): string {
const err = (error || {}) as APIErrorLike
return err.response?.data?.detail || err.response?.data?.message || err.message || ''
}
export function buildAuthErrorMessage(
error: unknown,
options: {
fallback: string
}
): string {
const { fallback } = options
const message = extractErrorMessage(error)
return message || fallback
}

View File

@@ -0,0 +1,46 @@
/**
* Shared URL builder for iframe-embedded pages.
* Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs
* with user_id, token, theme, ui_mode, src_host, and src parameters.
*/
const EMBEDDED_USER_ID_QUERY_KEY = 'user_id'
const EMBEDDED_AUTH_TOKEN_QUERY_KEY = 'token'
const EMBEDDED_THEME_QUERY_KEY = 'theme'
const EMBEDDED_UI_MODE_QUERY_KEY = 'ui_mode'
const EMBEDDED_UI_MODE_VALUE = 'embedded'
const EMBEDDED_SRC_HOST_QUERY_KEY = 'src_host'
const EMBEDDED_SRC_QUERY_KEY = 'src_url'
export function buildEmbeddedUrl(
baseUrl: string,
userId?: number,
authToken?: string | null,
theme: 'light' | 'dark' = 'light',
): string {
if (!baseUrl) return baseUrl
try {
const url = new URL(baseUrl)
if (userId) {
url.searchParams.set(EMBEDDED_USER_ID_QUERY_KEY, String(userId))
}
if (authToken) {
url.searchParams.set(EMBEDDED_AUTH_TOKEN_QUERY_KEY, authToken)
}
url.searchParams.set(EMBEDDED_THEME_QUERY_KEY, theme)
url.searchParams.set(EMBEDDED_UI_MODE_QUERY_KEY, EMBEDDED_UI_MODE_VALUE)
// Source tracking: let the embedded page know where it's being loaded from
if (typeof window !== 'undefined') {
url.searchParams.set(EMBEDDED_SRC_HOST_QUERY_KEY, window.location.origin)
url.searchParams.set(EMBEDDED_SRC_QUERY_KEY, window.location.href)
}
return url.toString()
} catch {
return baseUrl
}
}
export function detectTheme(): 'light' | 'dark' {
if (typeof document === 'undefined') return 'light'
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
}

View File

@@ -0,0 +1,61 @@
export const OPENAI_WS_MODE_OFF = 'off'
export const OPENAI_WS_MODE_SHARED = 'shared'
export const OPENAI_WS_MODE_DEDICATED = 'dedicated'
export type OpenAIWSMode =
| typeof OPENAI_WS_MODE_OFF
| typeof OPENAI_WS_MODE_SHARED
| typeof OPENAI_WS_MODE_DEDICATED
const OPENAI_WS_MODES = new Set<OpenAIWSMode>([
OPENAI_WS_MODE_OFF,
OPENAI_WS_MODE_SHARED,
OPENAI_WS_MODE_DEDICATED
])
export interface ResolveOpenAIWSModeOptions {
modeKey: string
enabledKey: string
fallbackEnabledKeys?: string[]
defaultMode?: OpenAIWSMode
}
export const normalizeOpenAIWSMode = (mode: unknown): OpenAIWSMode | null => {
if (typeof mode !== 'string') return null
const normalized = mode.trim().toLowerCase()
if (OPENAI_WS_MODES.has(normalized as OpenAIWSMode)) {
return normalized as OpenAIWSMode
}
return null
}
export const openAIWSModeFromEnabled = (enabled: unknown): OpenAIWSMode | null => {
if (typeof enabled !== 'boolean') return null
return enabled ? OPENAI_WS_MODE_SHARED : OPENAI_WS_MODE_OFF
}
export const isOpenAIWSModeEnabled = (mode: OpenAIWSMode): boolean => {
return mode !== OPENAI_WS_MODE_OFF
}
export const resolveOpenAIWSModeFromExtra = (
extra: Record<string, unknown> | null | undefined,
options: ResolveOpenAIWSModeOptions
): OpenAIWSMode => {
const fallback = options.defaultMode ?? OPENAI_WS_MODE_OFF
if (!extra) return fallback
const mode = normalizeOpenAIWSMode(extra[options.modeKey])
if (mode) return mode
const enabledMode = openAIWSModeFromEnabled(extra[options.enabledKey])
if (enabledMode) return enabledMode
const fallbackKeys = options.fallbackEnabledKeys ?? []
for (const key of fallbackKeys) {
const modeFromFallbackKey = openAIWSModeFromEnabled(extra[key])
if (modeFromFallbackKey) return modeFromFallbackKey
}
return fallback
}

View File

@@ -0,0 +1,115 @@
const EMAIL_SUFFIX_TOKEN_SPLIT_RE = /[\s,]+/
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
const EMAIL_SUFFIX_DOMAIN_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/
// normalizeRegistrationEmailSuffixDomain converts raw input into a canonical domain token.
// It removes leading "@", lowercases input, and strips all invalid characters.
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
value = value.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
return value
}
export function normalizeRegistrationEmailSuffixDomains(
items: string[] | null | undefined
): string[] {
if (!items || items.length === 0) {
return []
}
const seen = new Set<string>()
const normalized: string[] = []
for (const item of items) {
const domain = normalizeRegistrationEmailSuffixDomain(item)
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
continue
}
seen.add(domain)
normalized.push(domain)
}
return normalized
}
export function parseRegistrationEmailSuffixWhitelistInput(input: string): string[] {
if (!input || !input.trim()) {
return []
}
const seen = new Set<string>()
const normalized: string[] = []
for (const token of input.split(EMAIL_SUFFIX_TOKEN_SPLIT_RE)) {
const domain = normalizeRegistrationEmailSuffixDomainStrict(token)
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
continue
}
seen.add(domain)
normalized.push(domain)
}
return normalized
}
export function normalizeRegistrationEmailSuffixWhitelist(
items: string[] | null | undefined
): string[] {
return normalizeRegistrationEmailSuffixDomains(items).map((domain) => `@${domain}`)
}
function extractRegistrationEmailDomain(email: string): string {
const raw = String(email || '').trim().toLowerCase()
if (!raw) {
return ''
}
const atIndex = raw.indexOf('@')
if (atIndex <= 0 || atIndex >= raw.length - 1) {
return ''
}
if (raw.indexOf('@', atIndex + 1) !== -1) {
return ''
}
return raw.slice(atIndex + 1)
}
export function isRegistrationEmailSuffixAllowed(
email: string,
whitelist: string[] | null | undefined
): boolean {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(whitelist)
if (normalizedWhitelist.length === 0) {
return true
}
const emailDomain = extractRegistrationEmailDomain(email)
if (!emailDomain) {
return false
}
const emailSuffix = `@${emailDomain}`
return normalizedWhitelist.includes(emailSuffix)
}
// Pasted domains should be strict: any invalid character drops the whole token.
function normalizeRegistrationEmailSuffixDomainStrict(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
if (!value || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
return ''
}
return value
}
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
if (!domain) {
return false
}
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
}

View File

@@ -0,0 +1,6 @@
import DOMPurify from 'dompurify'
export function sanitizeSvg(svg: string): string {
if (!svg) return ''
return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } })
}

View File

@@ -0,0 +1,308 @@
export interface ParsedSoraTokens {
sessionTokens: string[]
accessTokens: string[]
}
const sessionKeyNames = new Set(['sessiontoken', 'session_token', 'st'])
const accessKeyNames = new Set(['accesstoken', 'access_token', 'at'])
const sessionRegexes = [
/\bsessionToken\b\s*:\s*["']([^"']+)["']/gi,
/\bsession_token\b\s*:\s*["']([^"']+)["']/gi
]
const accessRegexes = [
/\baccessToken\b\s*:\s*["']([^"']+)["']/gi,
/\baccess_token\b\s*:\s*["']([^"']+)["']/gi
]
const sessionCookieRegex =
/(?:^|[\n\r;])\s*(?:(?:set-cookie|cookie)\s*:\s*)?__Secure-(?:next-auth|authjs)\.session-token(?:\.(\d+))?=([^;\r\n]+)/gi
interface SessionCookieChunk {
index: number
value: string
}
const ignoredPlainLines = new Set([
'set-cookie',
'cookie',
'strict-transport-security',
'vary',
'x-content-type-options',
'x-openai-proxy-wasm'
])
function sanitizeToken(raw: string): string {
return raw.trim().replace(/^["'`]+|["'`,;]+$/g, '')
}
function addUnique(list: string[], seen: Set<string>, rawValue: string): void {
const token = sanitizeToken(rawValue)
if (!token || seen.has(token)) {
return
}
seen.add(token)
list.push(token)
}
function isLikelyJWT(token: string): boolean {
if (!token.startsWith('eyJ')) {
return false
}
return token.split('.').length === 3
}
function collectFromObject(
value: unknown,
sessionTokens: string[],
sessionSeen: Set<string>,
accessTokens: string[],
accessSeen: Set<string>
): void {
if (Array.isArray(value)) {
for (const item of value) {
collectFromObject(item, sessionTokens, sessionSeen, accessTokens, accessSeen)
}
return
}
if (!value || typeof value !== 'object') {
return
}
for (const [key, fieldValue] of Object.entries(value as Record<string, unknown>)) {
if (typeof fieldValue === 'string') {
const normalizedKey = key.toLowerCase()
if (sessionKeyNames.has(normalizedKey)) {
addUnique(sessionTokens, sessionSeen, fieldValue)
}
if (accessKeyNames.has(normalizedKey)) {
addUnique(accessTokens, accessSeen, fieldValue)
}
continue
}
collectFromObject(fieldValue, sessionTokens, sessionSeen, accessTokens, accessSeen)
}
}
function collectFromJSONString(
raw: string,
sessionTokens: string[],
sessionSeen: Set<string>,
accessTokens: string[],
accessSeen: Set<string>
): void {
const trimmed = raw.trim()
if (!trimmed) {
return
}
const candidates = [trimmed]
const firstBrace = trimmed.indexOf('{')
const lastBrace = trimmed.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace > firstBrace) {
candidates.push(trimmed.slice(firstBrace, lastBrace + 1))
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate)
collectFromObject(parsed, sessionTokens, sessionSeen, accessTokens, accessSeen)
return
} catch {
// ignore and keep trying other candidates
}
}
}
function collectByRegex(
raw: string,
regexes: RegExp[],
tokens: string[],
seen: Set<string>
): void {
for (const regex of regexes) {
regex.lastIndex = 0
let match: RegExpExecArray | null
match = regex.exec(raw)
while (match) {
if (match[1]) {
addUnique(tokens, seen, match[1])
}
match = regex.exec(raw)
}
}
}
function collectFromSessionCookies(
raw: string,
sessionTokens: string[],
sessionSeen: Set<string>
): void {
const chunkMatches: SessionCookieChunk[] = []
const singleValues: string[] = []
sessionCookieRegex.lastIndex = 0
let match: RegExpExecArray | null
match = sessionCookieRegex.exec(raw)
while (match) {
const chunkIndex = match[1]
const rawValue = match[2]
const value = sanitizeToken(rawValue || '')
if (value) {
if (chunkIndex !== undefined && chunkIndex !== '') {
const idx = Number.parseInt(chunkIndex, 10)
if (Number.isInteger(idx) && idx >= 0) {
chunkMatches.push({ index: idx, value })
}
} else {
singleValues.push(value)
}
}
match = sessionCookieRegex.exec(raw)
}
const mergedChunkToken = mergeLatestChunkedSessionToken(chunkMatches)
if (mergedChunkToken) {
addUnique(sessionTokens, sessionSeen, mergedChunkToken)
}
for (const value of singleValues) {
addUnique(sessionTokens, sessionSeen, value)
}
}
function mergeChunkSegment(
chunks: SessionCookieChunk[],
requiredMaxIndex: number,
requireComplete: boolean
): string {
if (chunks.length === 0) {
return ''
}
const byIndex = new Map<number, string>()
for (const chunk of chunks) {
byIndex.set(chunk.index, chunk.value)
}
if (!byIndex.has(0)) {
return ''
}
if (requireComplete) {
for (let i = 0; i <= requiredMaxIndex; i++) {
if (!byIndex.has(i)) {
return ''
}
}
}
const orderedIndexes = Array.from(byIndex.keys()).sort((a, b) => a - b)
return orderedIndexes.map((idx) => byIndex.get(idx) || '').join('')
}
function mergeLatestChunkedSessionToken(chunks: SessionCookieChunk[]): string {
if (chunks.length === 0) {
return ''
}
const requiredMaxIndex = chunks.reduce((max, chunk) => Math.max(max, chunk.index), 0)
const groupStarts: number[] = []
chunks.forEach((chunk, idx) => {
if (chunk.index === 0) {
groupStarts.push(idx)
}
})
if (groupStarts.length === 0) {
return mergeChunkSegment(chunks, requiredMaxIndex, false)
}
for (let i = groupStarts.length - 1; i >= 0; i--) {
const start = groupStarts[i]
const end = i + 1 < groupStarts.length ? groupStarts[i + 1] : chunks.length
const merged = mergeChunkSegment(chunks.slice(start, end), requiredMaxIndex, true)
if (merged) {
return merged
}
}
return mergeChunkSegment(chunks, requiredMaxIndex, false)
}
function collectPlainLines(
raw: string,
sessionTokens: string[],
sessionSeen: Set<string>,
accessTokens: string[],
accessSeen: Set<string>
): void {
const lines = raw
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
for (const line of lines) {
const normalized = line.toLowerCase()
if (ignoredPlainLines.has(normalized)) {
continue
}
if (/^__secure-(next-auth|authjs)\.session-token(\.\d+)?=/i.test(line)) {
continue
}
if (line.includes(';')) {
continue
}
if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(line)) {
const parts = line.split('=', 2)
const key = parts[0]?.trim().toLowerCase()
const value = parts[1]?.trim() || ''
if (key && sessionKeyNames.has(key)) {
addUnique(sessionTokens, sessionSeen, value)
continue
}
if (key && accessKeyNames.has(key)) {
addUnique(accessTokens, accessSeen, value)
continue
}
}
if (line.includes('{') || line.includes('}') || line.includes(':') || /\s/.test(line)) {
continue
}
if (isLikelyJWT(line)) {
addUnique(accessTokens, accessSeen, line)
continue
}
addUnique(sessionTokens, sessionSeen, line)
}
}
export function parseSoraRawTokens(rawInput: string): ParsedSoraTokens {
const raw = rawInput.trim()
if (!raw) {
return {
sessionTokens: [],
accessTokens: []
}
}
const sessionTokens: string[] = []
const accessTokens: string[] = []
const sessionSeen = new Set<string>()
const accessSeen = new Set<string>()
collectFromJSONString(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
collectByRegex(raw, sessionRegexes, sessionTokens, sessionSeen)
collectByRegex(raw, accessRegexes, accessTokens, accessSeen)
collectFromSessionCookies(raw, sessionTokens, sessionSeen)
collectPlainLines(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
return {
sessionTokens,
accessTokens
}
}

View File

@@ -0,0 +1,33 @@
import type { UsageRequestType } from '@/types'
export interface UsageRequestTypeLike {
request_type?: string | null
stream?: boolean | null
openai_ws_mode?: boolean | null
}
const VALID_REQUEST_TYPES = new Set<UsageRequestType>(['unknown', 'sync', 'stream', 'ws_v2'])
export const isUsageRequestType = (value: unknown): value is UsageRequestType => {
return typeof value === 'string' && VALID_REQUEST_TYPES.has(value as UsageRequestType)
}
export const resolveUsageRequestType = (value: UsageRequestTypeLike): UsageRequestType => {
if (isUsageRequestType(value.request_type)) {
return value.request_type
}
if (value.openai_ws_mode) {
return 'ws_v2'
}
return value.stream ? 'stream' : 'sync'
}
export const requestTypeToLegacyStream = (requestType?: UsageRequestType | null): boolean | null | undefined => {
if (!requestType || requestType === 'unknown') {
return null
}
if (requestType === 'sync') {
return false
}
return true
}

View File

@@ -184,7 +184,11 @@
</button>
</template>
<template #cell-today_stats="{ row }">
<AccountTodayStatsCell :account="row" />
<AccountTodayStatsCell
:stats="todayStatsByAccountId[String(row.id)] ?? null"
:loading="todayStatsLoading"
:error="todayStatsError"
/>
</template>
<template #cell-groups="{ row }">
<AccountGroupsCell :groups="row.groups" :max-display="4" />
@@ -259,7 +263,7 @@
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :selected-types="selTypes" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
@@ -273,7 +277,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, toRaw } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, toRaw, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -303,7 +307,7 @@ import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, AccountPlatform, Proxy, AdminGroup } from '@/types'
import type { Account, AccountPlatform, AccountType, Proxy, AdminGroup, WindowStats } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
@@ -320,6 +324,14 @@ const selPlatforms = computed<AccountPlatform[]>(() => {
)
return [...platforms]
})
const selTypes = computed<AccountType[]>(() => {
const types = new Set(
accounts.value
.filter(a => selIds.value.includes(a.id))
.map(a => a.type)
)
return [...types]
})
const showCreate = ref(false)
const showEdit = ref(false)
const showSync = ref(false)
@@ -347,7 +359,7 @@ const exportingData = ref(false)
const showColumnDropdown = ref(false)
const columnDropdownRef = ref<HTMLElement | null>(null)
const hiddenColumns = reactive<Set<string>>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings
@@ -366,6 +378,59 @@ const autoRefreshFetching = ref(false)
const AUTO_REFRESH_SILENT_WINDOW_MS = 15000
const autoRefreshSilentUntil = ref(0)
const hasPendingListSync = ref(false)
const todayStatsByAccountId = ref<Record<string, WindowStats>>({})
const todayStatsLoading = ref(false)
const todayStatsError = ref<string | null>(null)
const todayStatsReqSeq = ref(0)
const pendingTodayStatsRefresh = ref(false)
const buildDefaultTodayStats = (): WindowStats => ({
requests: 0,
tokens: 0,
cost: 0,
standard_cost: 0,
user_cost: 0
})
const refreshTodayStatsBatch = async () => {
if (hiddenColumns.has('today_stats')) {
todayStatsLoading.value = false
todayStatsError.value = null
return
}
const accountIDs = accounts.value.map(account => account.id)
const reqSeq = ++todayStatsReqSeq.value
if (accountIDs.length === 0) {
todayStatsByAccountId.value = {}
todayStatsError.value = null
todayStatsLoading.value = false
return
}
todayStatsLoading.value = true
todayStatsError.value = null
try {
const result = await adminAPI.accounts.getBatchTodayStats(accountIDs)
if (reqSeq !== todayStatsReqSeq.value) return
const serverStats = result.stats ?? {}
const nextStats: Record<string, WindowStats> = {}
for (const accountID of accountIDs) {
const key = String(accountID)
nextStats[key] = serverStats[key] ?? buildDefaultTodayStats()
}
todayStatsByAccountId.value = nextStats
} catch (error) {
if (reqSeq !== todayStatsReqSeq.value) return
todayStatsError.value = 'Failed'
console.error('Failed to load account today stats:', error)
} finally {
if (reqSeq === todayStatsReqSeq.value) {
todayStatsLoading.value = false
}
}
}
const autoRefreshIntervalLabel = (sec: number) => {
if (sec === 5) return t('admin.accounts.refreshInterval5s')
@@ -453,12 +518,18 @@ const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number])
}
const toggleColumn = (key: string) => {
const wasHidden = hiddenColumns.has(key)
if (hiddenColumns.has(key)) {
hiddenColumns.delete(key)
} else {
hiddenColumns.add(key)
}
saveColumnsToStorage()
if (key === 'today_stats' && wasHidden) {
refreshTodayStatsBatch().catch((error) => {
console.error('Failed to load account today stats after showing column:', error)
})
}
}
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
@@ -475,7 +546,7 @@ const {
handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', group: '', search: '' }
initialParams: { platform: '', type: '', status: '', group: '', search: '', lite: '1' }
})
const resetAutoRefreshCache = () => {
@@ -485,33 +556,49 @@ const resetAutoRefreshCache = () => {
const load = async () => {
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = false
await baseLoad()
await refreshTodayStatsBatch()
}
const reload = async () => {
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = false
await baseReload()
await refreshTodayStatsBatch()
}
const debouncedReload = () => {
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
baseDebouncedReload()
}
const handlePageChange = (page: number) => {
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
baseHandlePageChange(page)
}
const handlePageSizeChange = (size: number) => {
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
baseHandlePageSizeChange(size)
}
watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false
refreshTodayStatsBatch().catch((error) => {
console.error('Failed to refresh account today stats after table load:', error)
})
}
})
const isAnyModalOpen = computed(() => {
return (
showCreate.value ||
@@ -602,6 +689,7 @@ const refreshAccountsIncrementally = async () => {
type?: string
status?: string
search?: string
lite?: string
},
{ etag: autoRefreshETag.value }
)
@@ -609,14 +697,14 @@ const refreshAccountsIncrementally = async () => {
if (result.etag) {
autoRefreshETag.value = result.etag
}
if (result.notModified || !result.data) {
return
if (!result.notModified && result.data) {
pagination.total = result.data.total || 0
pagination.pages = result.data.pages || 0
mergeAccountsIncrementally(result.data.items || [])
hasPendingListSync.value = false
}
pagination.total = result.data.total || 0
pagination.pages = result.data.pages || 0
mergeAccountsIncrementally(result.data.items || [])
hasPendingListSync.value = false
await refreshTodayStatsBatch()
} catch (error) {
console.error('Auto refresh failed:', error)
} finally {

View File

@@ -246,7 +246,10 @@
{{ t('admin.dashboard.recentUsage') }} (Top 12)
</h3>
<div class="h-64">
<Line v-if="userTrendChartData" :data="userTrendChartData" :options="lineOptions" />
<div v-if="userTrendLoading" class="flex h-full items-center justify-center">
<LoadingSpinner size="md" />
</div>
<Line v-else-if="userTrendChartData" :data="userTrendChartData" :options="lineOptions" />
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
@@ -306,11 +309,14 @@ const appStore = useAppStore()
const stats = ref<DashboardStats | null>(null)
const loading = ref(false)
const chartsLoading = ref(false)
const userTrendLoading = ref(false)
// Chart data
const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([])
let chartLoadSeq = 0
let usersTrendLoadSeq = 0
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
@@ -366,6 +372,11 @@ const lineOptions = computed(() => ({
}
},
tooltip: {
itemSort: (a: any, b: any) => {
const aValue = typeof a?.raw === 'number' ? a.raw : Number(a?.parsed?.y ?? 0)
const bValue = typeof b?.raw === 'number' ? b.raw : Number(b?.parsed?.y ?? 0)
return bValue - aValue
},
callbacks: {
label: (context: any) => {
return `${context.dataset.label}: ${formatTokens(context.raw)}`
@@ -513,46 +524,74 @@ const onDateRangeChange = (range: {
}
// Load data
const loadDashboardStats = async () => {
loading.value = true
try {
stats.value = await adminAPI.dashboard.getStats()
} catch (error) {
appStore.showError(t('admin.dashboard.failedToLoad'))
console.error('Error loading dashboard stats:', error)
} finally {
loading.value = false
const loadDashboardSnapshot = async (includeStats: boolean) => {
const currentSeq = ++chartLoadSeq
if (includeStats && !stats.value) {
loading.value = true
}
}
const loadChartData = async () => {
chartsLoading.value = true
try {
const params = {
const response = await adminAPI.dashboard.getSnapshotV2({
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.value
granularity: granularity.value,
include_stats: includeStats,
include_trend: true,
include_model_stats: true,
include_group_stats: false,
include_users_trend: false
})
if (currentSeq !== chartLoadSeq) return
if (includeStats && response.stats) {
stats.value = response.stats
}
const [trendResponse, modelResponse, userResponse] = await Promise.all([
adminAPI.dashboard.getUsageTrend(params),
adminAPI.dashboard.getModelStats({ start_date: startDate.value, end_date: endDate.value }),
adminAPI.dashboard.getUserUsageTrend({ ...params, limit: 12 })
])
trendData.value = trendResponse.trend || []
modelStats.value = modelResponse.models || []
userTrend.value = userResponse.trend || []
trendData.value = response.trend || []
modelStats.value = response.models || []
} catch (error) {
console.error('Error loading chart data:', error)
if (currentSeq !== chartLoadSeq) return
appStore.showError(t('admin.dashboard.failedToLoad'))
console.error('Error loading dashboard snapshot:', error)
} finally {
if (currentSeq !== chartLoadSeq) return
loading.value = false
chartsLoading.value = false
}
}
const loadUsersTrend = async () => {
const currentSeq = ++usersTrendLoadSeq
userTrendLoading.value = true
try {
const response = await adminAPI.dashboard.getUserUsageTrend({
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.value,
limit: 12
})
if (currentSeq !== usersTrendLoadSeq) return
userTrend.value = response.trend || []
} catch (error) {
if (currentSeq !== usersTrendLoadSeq) return
console.error('Error loading users trend:', error)
userTrend.value = []
} finally {
if (currentSeq !== usersTrendLoadSeq) return
userTrendLoading.value = false
}
}
const loadDashboardStats = async () => {
await loadDashboardSnapshot(true)
void loadUsersTrend()
}
const loadChartData = async () => {
await loadDashboardSnapshot(false)
void loadUsersTrend()
}
onMounted(() => {
loadDashboardStats()
loadChartData()
})
</script>

View File

@@ -0,0 +1,514 @@
<template>
<AppLayout>
<div class="space-y-6">
<div class="card p-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.soraS3.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraS3.description') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" @click="startCreateSoraProfile">
{{ t('admin.settings.soraS3.newProfile') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="loadingSoraProfiles" @click="loadSoraS3Profiles">
{{ loadingSoraProfiles ? t('common.loading') : t('admin.settings.soraS3.reloadProfiles') }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[1000px] text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profile') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.active') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.endpoint') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.bucket') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.quota') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.updatedAt') }}</th>
<th class="py-2">{{ t('admin.settings.soraS3.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in soraS3Profiles" :key="profile.profile_id" class="border-b border-gray-100 align-top dark:border-dark-800">
<td class="py-3 pr-4">
<div class="font-mono text-xs">{{ profile.profile_id }}</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{{ profile.name }}</div>
</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
:class="profile.is_active ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-700 dark:bg-dark-800 dark:text-gray-300'"
>
{{ profile.is_active ? t('common.enabled') : t('common.disabled') }}
</span>
</td>
<td class="py-3 pr-4 text-xs">
<div>{{ profile.endpoint || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.region || '-' }}</div>
</td>
<td class="py-3 pr-4 text-xs">{{ profile.bucket || '-' }}</td>
<td class="py-3 pr-4 text-xs">{{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }}</td>
<td class="py-3 pr-4 text-xs">{{ formatDate(profile.updated_at) }}</td>
<td class="py-3 text-xs">
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-xs" @click="editSoraProfile(profile.profile_id)">
{{ t('common.edit') }}
</button>
<button
v-if="!profile.is_active"
type="button"
class="btn btn-secondary btn-xs"
:disabled="activatingSoraProfile"
@click="activateSoraProfile(profile.profile_id)"
>
{{ t('admin.settings.soraS3.activateProfile') }}
</button>
<button
type="button"
class="btn btn-danger btn-xs"
:disabled="deletingSoraProfile"
@click="removeSoraProfile(profile.profile_id)"
>
{{ t('common.delete') }}
</button>
</div>
</td>
</tr>
<tr v-if="soraS3Profiles.length === 0">
<td colspan="7" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraS3.empty') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<Teleport to="body">
<Transition name="dm-drawer-mask">
<div
v-if="soraProfileDrawerOpen"
class="fixed inset-0 z-[54] bg-black/40 backdrop-blur-sm"
@click="closeSoraProfileDrawer"
></div>
</Transition>
<Transition name="dm-drawer-panel">
<div
v-if="soraProfileDrawerOpen"
class="fixed inset-y-0 right-0 z-[55] flex h-full w-full max-w-2xl flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-dark-700">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }}
</h4>
<button
type="button"
class="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-800 dark:hover:text-gray-200"
@click="closeSoraProfileDrawer"
>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<input
v-model="soraProfileForm.profile_id"
class="input w-full"
:placeholder="t('admin.settings.soraS3.profileID')"
:disabled="!creatingSoraProfile"
/>
<input
v-model="soraProfileForm.name"
class="input w-full"
:placeholder="t('admin.settings.soraS3.profileName')"
/>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="soraProfileForm.enabled" type="checkbox" />
<span>{{ t('admin.settings.soraS3.enabled') }}</span>
</label>
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
<input
v-model="soraProfileForm.secret_access_key"
type="password"
class="input w-full"
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
/>
<input v-model="soraProfileForm.cdn_url" class="input w-full" :placeholder="t('admin.settings.soraS3.cdnUrl')" />
<div>
<input
v-model.number="soraProfileForm.default_storage_quota_gb"
type="number"
min="0"
step="0.1"
class="input w-full"
:placeholder="t('admin.settings.soraS3.defaultQuota')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.defaultQuotaHint') }}</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
</label>
<label v-if="creatingSoraProfile" class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="soraProfileForm.set_active" type="checkbox" />
<span>{{ t('admin.settings.soraS3.setActive') }}</span>
</label>
</div>
</div>
<div class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
<button type="button" class="btn btn-secondary btn-sm" @click="closeSoraProfileDrawer">
{{ t('common.cancel') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingSoraProfile || !soraProfileForm.enabled" @click="testSoraProfileConnection">
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.testConnection') }}
</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSoraProfile" @click="saveSoraProfile">
{{ savingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.saveProfile') }}
</button>
</div>
</div>
</Transition>
</Teleport>
</AppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import type { SoraS3Profile } from '@/api/admin/settings'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
const { t } = useI18n()
const appStore = useAppStore()
const loadingSoraProfiles = ref(false)
const savingSoraProfile = ref(false)
const testingSoraProfile = ref(false)
const activatingSoraProfile = ref(false)
const deletingSoraProfile = ref(false)
const creatingSoraProfile = ref(false)
const soraProfileDrawerOpen = ref(false)
const soraS3Profiles = ref<SoraS3Profile[]>([])
const selectedSoraProfileID = ref('')
type SoraS3ProfileForm = {
profile_id: string
name: string
set_active: boolean
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
cdn_url: string
default_storage_quota_gb: number
}
const soraProfileForm = ref<SoraS3ProfileForm>(newDefaultSoraS3ProfileForm())
async function loadSoraS3Profiles() {
loadingSoraProfiles.value = true
try {
const result = await adminAPI.settings.listSoraS3Profiles()
soraS3Profiles.value = result.items || []
if (!creatingSoraProfile.value) {
const stillExists = selectedSoraProfileID.value
? soraS3Profiles.value.some((item) => item.profile_id === selectedSoraProfileID.value)
: false
if (!stillExists) {
selectedSoraProfileID.value = pickPreferredSoraProfileID()
}
syncSoraProfileFormWithSelection()
}
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
loadingSoraProfiles.value = false
}
}
function startCreateSoraProfile() {
creatingSoraProfile.value = true
selectedSoraProfileID.value = ''
soraProfileForm.value = newDefaultSoraS3ProfileForm()
soraProfileDrawerOpen.value = true
}
function editSoraProfile(profileID: string) {
selectedSoraProfileID.value = profileID
creatingSoraProfile.value = false
syncSoraProfileFormWithSelection()
soraProfileDrawerOpen.value = true
}
function closeSoraProfileDrawer() {
soraProfileDrawerOpen.value = false
if (creatingSoraProfile.value) {
creatingSoraProfile.value = false
selectedSoraProfileID.value = pickPreferredSoraProfileID()
syncSoraProfileFormWithSelection()
}
}
async function saveSoraProfile() {
if (!soraProfileForm.value.name.trim()) {
appStore.showError(t('admin.settings.soraS3.profileNameRequired'))
return
}
if (creatingSoraProfile.value && !soraProfileForm.value.profile_id.trim()) {
appStore.showError(t('admin.settings.soraS3.profileIDRequired'))
return
}
if (!creatingSoraProfile.value && !selectedSoraProfileID.value) {
appStore.showError(t('admin.settings.soraS3.profileSelectRequired'))
return
}
if (soraProfileForm.value.enabled) {
if (!soraProfileForm.value.endpoint.trim()) {
appStore.showError(t('admin.settings.soraS3.endpointRequired'))
return
}
if (!soraProfileForm.value.bucket.trim()) {
appStore.showError(t('admin.settings.soraS3.bucketRequired'))
return
}
if (!soraProfileForm.value.access_key_id.trim()) {
appStore.showError(t('admin.settings.soraS3.accessKeyRequired'))
return
}
}
savingSoraProfile.value = true
try {
if (creatingSoraProfile.value) {
const created = await adminAPI.settings.createSoraS3Profile({
profile_id: soraProfileForm.value.profile_id.trim(),
name: soraProfileForm.value.name.trim(),
set_active: soraProfileForm.value.set_active,
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
selectedSoraProfileID.value = created.profile_id
creatingSoraProfile.value = false
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileCreated'))
} else {
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, {
name: soraProfileForm.value.name.trim(),
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileSaved'))
}
await loadSoraS3Profiles()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
savingSoraProfile.value = false
}
}
async function testSoraProfileConnection() {
testingSoraProfile.value = true
try {
const result = await adminAPI.settings.testSoraS3Connection({
profile_id: creatingSoraProfile.value ? undefined : selectedSoraProfileID.value,
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
appStore.showSuccess(result.message || t('admin.settings.soraS3.testSuccess'))
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
testingSoraProfile.value = false
}
}
async function activateSoraProfile(profileID: string) {
activatingSoraProfile.value = true
try {
await adminAPI.settings.setActiveSoraS3Profile(profileID)
appStore.showSuccess(t('admin.settings.soraS3.profileActivated'))
await loadSoraS3Profiles()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
activatingSoraProfile.value = false
}
}
async function removeSoraProfile(profileID: string) {
if (!window.confirm(t('admin.settings.soraS3.deleteConfirm', { profileID }))) {
return
}
deletingSoraProfile.value = true
try {
await adminAPI.settings.deleteSoraS3Profile(profileID)
if (selectedSoraProfileID.value === profileID) {
selectedSoraProfileID.value = ''
}
appStore.showSuccess(t('admin.settings.soraS3.profileDeleted'))
await loadSoraS3Profiles()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
deletingSoraProfile.value = false
}
}
function formatDate(value?: string): string {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString()
}
function formatStorageQuotaGB(bytes: number): string {
if (!bytes || bytes <= 0) {
return '0 GB'
}
const gb = bytes / (1024 * 1024 * 1024)
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`
}
function pickPreferredSoraProfileID(): string {
const active = soraS3Profiles.value.find((item) => item.is_active)
if (active) {
return active.profile_id
}
return soraS3Profiles.value[0]?.profile_id || ''
}
function syncSoraProfileFormWithSelection() {
const profile = soraS3Profiles.value.find((item) => item.profile_id === selectedSoraProfileID.value)
soraProfileForm.value = newDefaultSoraS3ProfileForm(profile)
}
function newDefaultSoraS3ProfileForm(profile?: SoraS3Profile): SoraS3ProfileForm {
if (!profile) {
return {
profile_id: '',
name: '',
set_active: false,
enabled: false,
endpoint: '',
region: '',
bucket: '',
access_key_id: '',
secret_access_key: '',
secret_access_key_configured: false,
prefix: 'sora/',
force_path_style: false,
cdn_url: '',
default_storage_quota_gb: 0
}
}
const quotaBytes = profile.default_storage_quota_bytes || 0
return {
profile_id: profile.profile_id,
name: profile.name,
set_active: false,
enabled: profile.enabled,
endpoint: profile.endpoint || '',
region: profile.region || '',
bucket: profile.bucket || '',
access_key_id: profile.access_key_id || '',
secret_access_key: '',
secret_access_key_configured: Boolean(profile.secret_access_key_configured),
prefix: profile.prefix || '',
force_path_style: Boolean(profile.force_path_style),
cdn_url: profile.cdn_url || '',
default_storage_quota_gb: Number((quotaBytes / (1024 * 1024 * 1024)).toFixed(2))
}
}
onMounted(async () => {
await loadSoraS3Profiles()
})
</script>
<style scoped>
.dm-drawer-mask-enter-active,
.dm-drawer-mask-leave-active {
transition: opacity 0.2s ease;
}
.dm-drawer-mask-enter-from,
.dm-drawer-mask-leave-to {
opacity: 0;
}
.dm-drawer-panel-enter-active,
.dm-drawer-panel-leave-active {
transition:
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.2s ease;
}
.dm-drawer-panel-enter-from,
.dm-drawer-panel-leave-to {
opacity: 0.96;
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.dm-drawer-mask-enter-active,
.dm-drawer-mask-leave-active,
.dm-drawer-panel-enter-active,
.dm-drawer-panel-leave-active {
transition-duration: 0s;
}
}
</style>

View File

@@ -532,6 +532,23 @@
/>
</div>
</div>
<div class="mt-3">
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="createForm.sora_storage_quota_gb"
type="number"
step="0.1"
min="0"
class="input"
placeholder="10"
/>
<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.groups.soraPricing.storageQuotaHint') }}
</p>
</div>
</div>
<!-- 支持的模型系列 antigravity 平台 -->
@@ -1264,6 +1281,23 @@
/>
</div>
</div>
<div class="mt-3">
<label class="input-label">{{ t('admin.groups.soraPricing.storageQuota') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="editForm.sora_storage_quota_gb"
type="number"
step="0.1"
min="0"
class="input"
placeholder="10"
/>
<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.groups.soraPricing.storageQuotaHint') }}
</p>
</div>
</div>
<!-- 支持的模型系列 antigravity 平台 -->
@@ -1985,6 +2019,7 @@ const createForm = reactive({
sora_image_price_540: null as number | null,
sora_video_price_per_request: null as number | null,
sora_video_price_per_request_hd: null as number | null,
sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
// Claude Max usage 模拟开关(仅 anthropic 平台)
@@ -2227,6 +2262,7 @@ const editForm = reactive({
sora_image_price_540: null as number | null,
sora_video_price_per_request: null as number | null,
sora_video_price_per_request_hd: null as number | null,
sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
// Claude Max usage 模拟开关(仅 anthropic 平台)
@@ -2328,6 +2364,7 @@ const closeCreateModal = () => {
createForm.sora_image_price_540 = null
createForm.sora_video_price_per_request = null
createForm.sora_video_price_per_request_hd = null
createForm.sora_storage_quota_gb = null
createForm.claude_code_only = false
createForm.simulate_claude_max_enabled = false
createForm.fallback_group_id = null
@@ -2346,8 +2383,10 @@ const handleCreateGroup = async () => {
submitting.value = true
try {
// 构建请求数据,包含模型路由配置
const { sora_storage_quota_gb: createQuotaGb, ...createRest } = createForm
const requestData = {
...createForm,
...createRest,
sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0,
simulate_claude_max_enabled:
createForm.platform === 'anthropic' ? createForm.simulate_claude_max_enabled : false,
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
@@ -2388,6 +2427,7 @@ const handleEdit = async (group: AdminGroup) => {
editForm.sora_image_price_540 = group.sora_image_price_540
editForm.sora_video_price_per_request = group.sora_video_price_per_request
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
editForm.sora_storage_quota_gb = group.sora_storage_quota_bytes ? Number((group.sora_storage_quota_bytes / (1024 * 1024 * 1024)).toFixed(2)) : null
editForm.claude_code_only = group.claude_code_only || false
editForm.simulate_claude_max_enabled = group.simulate_claude_max_enabled || false
editForm.fallback_group_id = group.fallback_group_id
@@ -2423,8 +2463,10 @@ const handleUpdateGroup = async () => {
submitting.value = true
try {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const { sora_storage_quota_gb: editQuotaGb, ...editRest } = editForm
const payload = {
...editForm,
...editRest,
sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0,
simulate_claude_max_enabled:
editForm.platform === 'anthropic' ? editForm.simulate_claude_max_enabled : false,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,

View File

@@ -124,7 +124,54 @@
</template>
<template #cell-address="{ row }">
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
<div class="flex items-center gap-1.5">
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
<div class="relative">
<button
type="button"
class="rounded p-0.5 text-gray-400 hover:text-primary-600 dark:hover:text-primary-400"
:title="t('admin.proxies.copyProxyUrl')"
@click.stop="copyProxyUrl(row)"
@contextmenu.prevent="toggleCopyMenu(row.id)"
>
<Icon name="copy" size="sm" />
</button>
<!-- 右键展开格式选择菜单 -->
<div
v-if="copyMenuProxyId === row.id"
class="absolute left-0 top-full z-50 mt-1 w-auto min-w-[180px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-500 dark:bg-dark-700"
>
<button
v-for="fmt in getCopyFormats(row)"
:key="fmt.label"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-100 dark:hover:bg-dark-600"
@click.stop="copyFormat(fmt.value)"
>
<span class="truncate font-mono text-gray-600 dark:text-gray-300">{{ fmt.label }}</span>
</button>
</div>
</div>
</div>
</template>
<template #cell-auth="{ row }">
<div v-if="row.username || row.password" class="flex items-center gap-1.5">
<div class="flex flex-col text-xs">
<span v-if="row.username" class="text-gray-700 dark:text-gray-200">{{ row.username }}</span>
<span v-if="row.password" class="font-mono text-gray-500 dark:text-gray-400">
{{ visiblePasswordIds.has(row.id) ? row.password : '' }}
</span>
</div>
<button
v-if="row.password"
type="button"
class="ml-1 rounded p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@click.stop="visiblePasswordIds.has(row.id) ? visiblePasswordIds.delete(row.id) : visiblePasswordIds.add(row.id)"
>
<Icon :name="visiblePasswordIds.has(row.id) ? 'eyeOff' : 'eye'" size="sm" />
</button>
</div>
<span v-else class="text-sm text-gray-400">-</span>
</template>
<template #cell-location="{ row }">
@@ -397,12 +444,21 @@
</div>
<div>
<label class="input-label">{{ t('admin.proxies.password') }}</label>
<input
v-model="createForm.password"
type="password"
class="input"
:placeholder="t('admin.proxies.optionalAuth')"
/>
<div class="relative">
<input
v-model="createForm.password"
:type="createPasswordVisible ? 'text' : 'password'"
class="input pr-10"
:placeholder="t('admin.proxies.optionalAuth')"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@click="createPasswordVisible = !createPasswordVisible"
>
<Icon :name="createPasswordVisible ? 'eyeOff' : 'eye'" size="md" />
</button>
</div>
</div>
</form>
@@ -581,12 +637,22 @@
</div>
<div>
<label class="input-label">{{ t('admin.proxies.password') }}</label>
<input
v-model="editForm.password"
type="password"
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
class="input"
/>
<div class="relative">
<input
v-model="editForm.password"
:type="editPasswordVisible ? 'text' : 'password'"
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
class="input pr-10"
@input="editPasswordDirty = true"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@click="editPasswordVisible = !editPasswordVisible"
>
<Icon :name="editPasswordVisible ? 'eyeOff' : 'eye'" size="md" />
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.status') }}</label>
@@ -813,15 +879,18 @@ import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { useClipboard } from '@/composables/useClipboard'
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
const columns = computed<Column[]>(() => [
{ key: 'select', label: '', sortable: false },
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
{ key: 'auth', label: t('admin.proxies.columns.auth'), sortable: false },
{ key: 'location', label: t('admin.proxies.columns.location'), sortable: false },
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
@@ -858,6 +927,8 @@ const editStatusOptions = computed(() => [
])
const proxies = ref<Proxy[]>([])
const visiblePasswordIds = reactive(new Set<number>())
const copyMenuProxyId = ref<number | null>(null)
const loading = ref(false)
const searchQuery = ref('')
const filters = reactive({
@@ -872,7 +943,10 @@ const pagination = reactive({
})
const showCreateModal = ref(false)
const createPasswordVisible = ref(false)
const showEditModal = ref(false)
const editPasswordVisible = ref(false)
const editPasswordDirty = ref(false)
const showImportData = ref(false)
const showDeleteDialog = ref(false)
const showBatchDeleteDialog = ref(false)
@@ -1030,6 +1104,7 @@ const closeCreateModal = () => {
createForm.port = 8080
createForm.username = ''
createForm.password = ''
createPasswordVisible.value = false
batchInput.value = ''
batchParseResult.total = 0
batchParseResult.valid = 0
@@ -1173,14 +1248,18 @@ const handleEdit = (proxy: Proxy) => {
editForm.host = proxy.host
editForm.port = proxy.port
editForm.username = proxy.username || ''
editForm.password = ''
editForm.password = proxy.password || ''
editForm.status = proxy.status
editPasswordVisible.value = false
editPasswordDirty.value = false
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingProxy.value = null
editPasswordVisible.value = false
editPasswordDirty.value = false
}
const handleUpdateProxy = async () => {
@@ -1209,10 +1288,9 @@ const handleUpdateProxy = async () => {
status: editForm.status
}
// Only include password if it was changed
const trimmedPassword = editForm.password.trim()
if (trimmedPassword) {
updateData.password = trimmedPassword
// Only include password if user actually modified the field
if (editPasswordDirty.value) {
updateData.password = editForm.password.trim() || null
}
await adminAPI.proxies.update(editingProxy.value.id, updateData)
@@ -1715,12 +1793,60 @@ const closeAccountsModal = () => {
proxyAccounts.value = []
}
// ── Proxy URL copy ──
function buildAuthPart(row: any): string {
const user = row.username ? encodeURIComponent(row.username) : ''
const pass = row.password ? encodeURIComponent(row.password) : ''
if (user && pass) return `${user}:${pass}@`
if (user) return `${user}@`
if (pass) return `:${pass}@`
return ''
}
function buildProxyUrl(row: any): string {
return `${row.protocol}://${buildAuthPart(row)}${row.host}:${row.port}`
}
function getCopyFormats(row: any) {
const hasAuth = row.username || row.password
const fullUrl = buildProxyUrl(row)
const formats = [
{ label: fullUrl, value: fullUrl },
]
if (hasAuth) {
const withoutProtocol = fullUrl.replace(/^[^:]+:\/\//, '')
formats.push({ label: withoutProtocol, value: withoutProtocol })
}
formats.push({ label: `${row.host}:${row.port}`, value: `${row.host}:${row.port}` })
return formats
}
function copyProxyUrl(row: any) {
copyToClipboard(buildProxyUrl(row), t('admin.proxies.urlCopied'))
copyMenuProxyId.value = null
}
function toggleCopyMenu(id: number) {
copyMenuProxyId.value = copyMenuProxyId.value === id ? null : id
}
function copyFormat(value: string) {
copyToClipboard(value, t('admin.proxies.urlCopied'))
copyMenuProxyId.value = null
}
function closeCopyMenu() {
copyMenuProxyId.value = null
}
onMounted(() => {
loadProxies()
document.addEventListener('click', closeCopyMenu)
})
onUnmounted(() => {
clearTimeout(searchTimeout)
abortController?.abort()
document.removeEventListener('click', closeCopyMenu)
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -74,11 +74,12 @@ import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import { formatReasoningEffort } from '@/utils/format'
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'; import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
@@ -87,6 +88,7 @@ const appStore = useAppStore()
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
let chartReqSeq = 0
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
const cleanupDialogVisible = ref(false)
@@ -100,33 +102,72 @@ const formatLD = (d: Date) => {
}
const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6)
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const loadLogs = async () => {
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
try {
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, ...filters.value }, { signal: c.signal })
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, exact_total: false, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
}
const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch (error) { console.error('Failed to load usage stats:', error) } }
const loadStats = async () => {
try {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const s = await adminAPI.usage.getStats({ ...filters.value, stream: legacyStream === null ? undefined : legacyStream })
usageStats.value = s
} catch (error) {
console.error('Failed to load usage stats:', error)
}
}
const loadChartData = async () => {
const seq = ++chartReqSeq
chartsLoading.value = true
try {
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream, billing_type: filters.value.billing_type }
const statsParams = { start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream, billing_type: params.billing_type }
const [trendRes, modelRes, groupRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats(statsParams), adminAPI.dashboard.getGroupStats(statsParams)])
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []; groupStats.value = groupRes.groups || []
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const snapshot = await adminAPI.dashboard.getSnapshotV2({
start_date: filters.value.start_date || startDate.value,
end_date: filters.value.end_date || endDate.value,
granularity: granularity.value,
user_id: filters.value.user_id,
model: filters.value.model,
api_key_id: filters.value.api_key_id,
account_id: filters.value.account_id,
group_id: filters.value.group_id,
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
include_stats: false,
include_trend: true,
include_model_stats: true,
include_group_stats: true,
include_users_trend: false
})
if (seq !== chartReqSeq) return
trendData.value = snapshot.trend || []
modelStats.value = snapshot.models || []
groupStats.value = snapshot.groups || []
} catch (error) { console.error('Failed to load chart data:', error) } finally { if (seq === chartReqSeq) chartsLoading.value = false }
}
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, billing_type: null }; granularity.value = 'day'; applyFilters() }
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = 'day'; applyFilters() }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
const cancelExport = () => exportAbortController?.abort()
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
const getRequestTypeLabel = (log: AdminUsageLog): string => {
const requestType = resolveUsageRequestType(log)
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 exportToExcel = async () => {
if (exporting.value) return; exporting.value = true; exportProgress.show = true
@@ -148,11 +189,13 @@ const exportToExcel = async () => {
]
const ws = XLSX.utils.aoa_to_sheet([headers])
while (true) {
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const res = await adminUsageAPI.list({ page: p, page_size: 100, exact_total: true, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal })
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', log.stream ? t('usage.stream') : t('usage.sync'),
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', getRequestTypeLabel(log),
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
@@ -250,6 +293,14 @@ const handleColumnClickOutside = (event: MouseEvent) => {
}
}
onMounted(() => { loadLogs(); loadStats(); loadChartData(); loadSavedColumns(); document.addEventListener('click', handleColumnClickOutside) })
onMounted(() => {
loadLogs()
loadStats()
window.setTimeout(() => {
void loadChartData()
}, 120)
loadSavedColumns()
document.addEventListener('click', handleColumnClickOutside)
})
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
</script>

View File

@@ -655,16 +655,28 @@ const saveColumnsToStorage = () => {
// Toggle column visibility
const toggleColumn = (key: string) => {
const wasHidden = hiddenColumns.has(key)
if (hiddenColumns.has(key)) {
hiddenColumns.delete(key)
} else {
hiddenColumns.add(key)
}
saveColumnsToStorage()
if (wasHidden && (key === 'usage' || key.startsWith('attr_'))) {
refreshCurrentPageSecondaryData()
}
if (key === 'subscriptions') {
loadUsers()
}
}
// Check if column is visible (not in hidden set)
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
const hasVisibleUsageColumn = computed(() => !hiddenColumns.has('usage'))
const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
const hasVisibleAttributeColumns = computed(() =>
attributeDefinitions.value.some((def) => def.enabled && !hiddenColumns.has(`attr_${def.id}`))
)
// Filtered columns based on visibility
const columns = computed<Column[]>(() =>
@@ -776,6 +788,60 @@ const editingUser = ref<AdminUser | null>(null)
const deletingUser = ref<AdminUser | null>(null)
const viewingUser = ref<AdminUser | null>(null)
let abortController: AbortController | null = null
let secondaryDataSeq = 0
const loadUsersSecondaryData = async (
userIds: number[],
signal?: AbortSignal,
expectedSeq?: number
) => {
if (userIds.length === 0) return
const tasks: Promise<void>[] = []
if (hasVisibleUsageColumn.value) {
tasks.push(
(async () => {
try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal?.aborted) return
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
usageStats.value = usageResponse.stats
} catch (e) {
if (signal?.aborted) return
console.error('Failed to load usage stats:', e)
}
})()
)
}
if (attributeDefinitions.value.length > 0 && hasVisibleAttributeColumns.value) {
tasks.push(
(async () => {
try {
const attrResponse = await adminAPI.userAttributes.getBatchUserAttributes(userIds)
if (signal?.aborted) return
if (typeof expectedSeq === 'number' && expectedSeq !== secondaryDataSeq) return
userAttributeValues.value = attrResponse.attributes
} catch (e) {
if (signal?.aborted) return
console.error('Failed to load user attribute values:', e)
}
})()
)
}
if (tasks.length > 0) {
await Promise.allSettled(tasks)
}
}
const refreshCurrentPageSecondaryData = () => {
const userIds = users.value.map((u) => u.id)
if (userIds.length === 0) return
const seq = ++secondaryDataSeq
void loadUsersSecondaryData(userIds, undefined, seq)
}
// Action Menu State
const activeMenuId = ref<number | null>(null)
@@ -913,7 +979,8 @@ const loadUsers = async () => {
role: filters.role as any,
status: filters.status as any,
search: searchQuery.value || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
include_subscriptions: hasVisibleSubscriptionsColumn.value
},
{ signal }
)
@@ -923,38 +990,17 @@ const loadUsers = async () => {
users.value = response.items
pagination.total = response.total
pagination.pages = response.pages
usageStats.value = {}
userAttributeValues.value = {}
// Load usage stats and attribute values for all users in the list
// Defer heavy secondary data so table can render first.
if (response.items.length > 0) {
const userIds = response.items.map((u) => u.id)
// Load usage stats
try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal.aborted) {
return
}
usageStats.value = usageResponse.stats
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load usage stats:', e)
}
// Load attribute values
if (attributeDefinitions.value.length > 0) {
try {
const attrResponse = await adminAPI.userAttributes.getBatchUserAttributes(userIds)
if (signal.aborted) {
return
}
userAttributeValues.value = attrResponse.attributes
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load user attribute values:', e)
}
}
const seq = ++secondaryDataSeq
window.setTimeout(() => {
if (signal.aborted || seq !== secondaryDataSeq) return
void loadUsersSecondaryData(userIds, signal, seq)
}, 50)
}
} catch (error: any) {
const errorInfo = error as { name?: string; code?: string }

View File

@@ -586,6 +586,32 @@ async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortS
}
}
async function refreshCoreSnapshotWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingTrend.value = true
loadingErrorTrend.value = true
try {
const data = await opsAPI.getDashboardSnapshotV2(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
overview.value = data.overview
throughputTrend.value = data.throughput_trend
errorTrend.value = data.error_trend
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
// Fallback to legacy split endpoints when snapshot endpoint is unavailable.
await Promise.all([
refreshOverviewWithCancel(fetchSeq, signal),
refreshThroughputTrendWithCancel(fetchSeq, signal),
refreshErrorTrendWithCancel(fetchSeq, signal)
])
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingTrend.value = false
loadingErrorTrend.value = false
}
}
}
async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingLatency.value = true
@@ -640,6 +666,14 @@ async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: Abor
}
}
async function refreshDeferredPanels(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
await Promise.all([
refreshLatencyHistogramWithCancel(fetchSeq, signal),
refreshErrorDistributionWithCancel(fetchSeq, signal)
])
}
function isOpsDisabledError(err: unknown): boolean {
return (
!!err &&
@@ -662,12 +696,8 @@ async function fetchData() {
errorMessage.value = ''
try {
await Promise.all([
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshCoreSnapshotWithCancel(fetchSeq, dashboardFetchController.signal),
refreshSwitchTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
])
if (fetchSeq !== dashboardFetchSeq) return
@@ -680,6 +710,9 @@ async function fetchData() {
if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = Math.floor(autoRefreshIntervalMs.value / 1000)
}
// Defer non-core visual panels to reduce initial blocking.
void refreshDeferredPanels(fetchSeq, dashboardFetchController.signal)
} catch (err) {
if (!isOpsDisabledError(err)) {
console.error('[ops] failed to fetch dashboard data', err)

View File

@@ -167,6 +167,7 @@ import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores'
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
import { formatDateTime } from '@/utils/format'
import { resolvePrimaryResponseBody, resolveUpstreamPayload } from '../utils/errorDetailResponse'
interface Props {
show: boolean
@@ -192,11 +193,7 @@ const showUpstreamList = computed(() => props.errorType === 'request')
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
const primaryResponseBody = computed(() => {
if (!detail.value) return ''
if (props.errorType === 'upstream') {
return detail.value.upstream_error_detail || detail.value.upstream_errors || detail.value.upstream_error_message || detail.value.error_body || ''
}
return detail.value.error_body || ''
return resolvePrimaryResponseBody(detail.value, props.errorType)
})
@@ -224,7 +221,9 @@ const correlatedUpstreamErrors = computed<OpsErrorDetail[]>(() => correlatedUpst
const expandedUpstreamDetailIds = ref(new Set<number>())
function getUpstreamResponsePreview(ev: OpsErrorDetail): string {
return String(ev.upstream_error_detail || ev.error_body || ev.upstream_error_message || '').trim()
const upstreamPayload = resolveUpstreamPayload(ev)
if (upstreamPayload) return upstreamPayload
return String(ev.error_body || '').trim()
}
function toggleUpstreamDetail(id: number) {

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import type { OpsErrorDetail } from '@/api/admin/ops'
import { resolvePrimaryResponseBody, resolveUpstreamPayload } from '../errorDetailResponse'
function makeDetail(overrides: Partial<OpsErrorDetail>): OpsErrorDetail {
return {
id: 1,
created_at: '2026-01-01T00:00:00Z',
phase: 'request',
type: 'api_error',
error_owner: 'platform',
error_source: 'gateway',
severity: 'P2',
status_code: 502,
platform: 'openai',
model: 'gpt-4o-mini',
is_retryable: true,
retry_count: 0,
resolved: false,
client_request_id: 'crid-1',
request_id: 'rid-1',
message: 'Upstream request failed',
user_email: 'user@example.com',
account_name: 'acc',
group_name: 'group',
error_body: '',
user_agent: '',
request_body: '',
request_body_truncated: false,
is_business_limited: false,
...overrides
}
}
describe('errorDetailResponse', () => {
it('prefers upstream payload for request modal when error_body is generic gateway wrapper', () => {
const detail = makeDetail({
error_body: JSON.stringify({
type: 'error',
error: {
type: 'upstream_error',
message: 'Upstream request failed'
}
}),
upstream_error_detail: '{"provider_message":"real upstream detail"}'
})
expect(resolvePrimaryResponseBody(detail, 'request')).toBe('{"provider_message":"real upstream detail"}')
})
it('keeps error_body for request modal when body is not generic wrapper', () => {
const detail = makeDetail({
error_body: JSON.stringify({
type: 'error',
error: {
type: 'upstream_error',
message: 'Upstream authentication failed, please contact administrator'
}
}),
upstream_error_detail: '{"provider_message":"real upstream detail"}'
})
expect(resolvePrimaryResponseBody(detail, 'request')).toBe(detail.error_body)
})
it('uses upstream payload first in upstream modal', () => {
const detail = makeDetail({
phase: 'upstream',
upstream_error_message: 'provider 503 overloaded',
error_body: '{"type":"error","error":{"type":"upstream_error","message":"Upstream request failed"}}'
})
expect(resolvePrimaryResponseBody(detail, 'upstream')).toBe('provider 503 overloaded')
})
it('falls back to upstream payload when request error_body is empty', () => {
const detail = makeDetail({
error_body: '',
upstream_error_message: 'dial tcp timeout'
})
expect(resolvePrimaryResponseBody(detail, 'request')).toBe('dial tcp timeout')
})
it('resolves upstream payload by detail -> events -> message priority', () => {
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: 'detail payload',
upstream_errors: '[{"message":"event payload"}]',
upstream_error_message: 'message payload'
}))).toBe('detail payload')
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: '',
upstream_errors: '[{"message":"event payload"}]',
upstream_error_message: 'message payload'
}))).toBe('[{"message":"event payload"}]')
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: '',
upstream_errors: '',
upstream_error_message: 'message payload'
}))).toBe('message payload')
})
it('treats empty JSON placeholders in upstream payload as empty', () => {
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: '',
upstream_errors: '[]',
upstream_error_message: ''
}))).toBe('')
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: '',
upstream_errors: '{}',
upstream_error_message: ''
}))).toBe('')
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: '',
upstream_errors: 'null',
upstream_error_message: ''
}))).toBe('')
})
it('skips placeholder candidates and falls back to the next upstream field', () => {
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: '',
upstream_errors: '[]',
upstream_error_message: 'fallback message'
}))).toBe('fallback message')
expect(resolveUpstreamPayload(makeDetail({
upstream_error_detail: 'null',
upstream_errors: '',
upstream_error_message: 'fallback message'
}))).toBe('fallback message')
})
})

View File

@@ -0,0 +1,91 @@
import type { OpsErrorDetail } from '@/api/admin/ops'
const GENERIC_UPSTREAM_MESSAGES = new Set([
'upstream request failed',
'upstream request failed after retries',
'upstream gateway error',
'upstream service temporarily unavailable'
])
type ParsedGatewayError = {
type: string
message: string
}
function parseGatewayErrorBody(raw: string): ParsedGatewayError | null {
const text = String(raw || '').trim()
if (!text) return null
try {
const parsed = JSON.parse(text) as Record<string, any>
const err = parsed?.error as Record<string, any> | undefined
if (!err || typeof err !== 'object') return null
const type = typeof err.type === 'string' ? err.type.trim() : ''
const message = typeof err.message === 'string' ? err.message.trim() : ''
if (!type && !message) return null
return { type, message }
} catch {
return null
}
}
function isGenericGatewayUpstreamError(raw: string): boolean {
const parsed = parseGatewayErrorBody(raw)
if (!parsed) return false
if (parsed.type !== 'upstream_error') return false
return GENERIC_UPSTREAM_MESSAGES.has(parsed.message.toLowerCase())
}
export function resolveUpstreamPayload(
detail: Pick<OpsErrorDetail, 'upstream_error_detail' | 'upstream_errors' | 'upstream_error_message'> | null | undefined
): string {
if (!detail) return ''
const candidates = [
detail.upstream_error_detail,
detail.upstream_errors,
detail.upstream_error_message
]
for (const candidate of candidates) {
const payload = String(candidate || '').trim()
if (!payload) continue
// Normalize common "empty but present" JSON placeholders.
if (payload === '[]' || payload === '{}' || payload.toLowerCase() === 'null') {
continue
}
return payload
}
return ''
}
export function resolvePrimaryResponseBody(
detail: OpsErrorDetail | null,
errorType?: 'request' | 'upstream'
): string {
if (!detail) return ''
const upstreamPayload = resolveUpstreamPayload(detail)
const errorBody = String(detail.error_body || '').trim()
if (errorType === 'upstream') {
return upstreamPayload || errorBody
}
if (!errorBody) {
return upstreamPayload
}
// For request detail modal, keep client-visible body by default.
// But if that body is a generic gateway wrapper, show upstream payload first.
if (upstreamPayload && isGenericGatewayUpstreamError(errorBody)) {
return upstreamPayload
}
return errorBody
}

View File

@@ -7,7 +7,7 @@
{{ t('auth.verifyYourEmail') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
We'll send a verification code to
{{ t('auth.sendCodeDesc') }}
<span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
</p>
</div>
@@ -64,7 +64,7 @@
<Icon name="checkCircle" size="md" class="text-green-500" />
</div>
<p class="text-sm text-green-700 dark:text-green-400">
Verification code sent! Please check your inbox.
{{ t('auth.codeSentSuccess') }}
</p>
</div>
</div>
@@ -123,7 +123,7 @@
></path>
</svg>
<Icon v-else name="checkCircle" size="md" class="mr-2" />
{{ isLoading ? 'Verifying...' : 'Verify & Create Account' }}
{{ isLoading ? t('auth.verifying') : t('auth.verifyAndCreate') }}
</button>
<!-- Resend Code -->
@@ -134,7 +134,7 @@
disabled
class="cursor-not-allowed text-sm text-gray-400 dark:text-dark-500"
>
Resend code in {{ countdown }}s
{{ t('auth.resendCountdown', { countdown }) }}
</button>
<button
v-else
@@ -162,7 +162,7 @@
class="flex items-center gap-2 text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-gray-300"
>
<Icon name="arrowLeft" size="sm" />
Back to registration
{{ t('auth.backToRegistration') }}
</button>
</template>
</AuthLayout>
@@ -177,8 +177,13 @@ import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n()
const { t, locale } = useI18n()
// ==================== Router & Stores ====================
@@ -208,6 +213,7 @@ const hasRegisterData = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile for resend
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -244,6 +250,9 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
} catch (error) {
console.error('Failed to load public settings:', error)
}
@@ -291,12 +300,12 @@ function onTurnstileVerify(token: string): void {
function onTurnstileExpire(): void {
resendTurnstileToken.value = ''
errors.value.turnstile = 'Verification expired, please try again'
errors.value.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
resendTurnstileToken.value = ''
errors.value.turnstile = 'Verification failed, please try again'
errors.value.turnstile = t('auth.turnstileFailed')
}
// ==================== Send Code ====================
@@ -306,6 +315,12 @@ async function sendCode(): Promise<void> {
errorMessage.value = ''
try {
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
errorMessage.value = buildEmailSuffixNotAllowedMessage()
appStore.showError(errorMessage.value)
return
}
const response = await sendVerifyCode({
email: email.value,
// 优先使用重发时新获取的 token因为初始 token 可能已被使用)
@@ -320,15 +335,9 @@ async function sendCode(): Promise<void> {
showResendTurnstile.value = false
resendTurnstileToken.value = ''
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Failed to send verification code. Please try again.'
}
errorMessage.value = buildAuthErrorMessage(error, {
fallback: t('auth.sendCodeFailed')
})
appStore.showError(errorMessage.value)
} finally {
@@ -347,7 +356,7 @@ async function handleResendCode(): Promise<void> {
// If turnstile is enabled but no token yet, wait
if (turnstileEnabled.value && !resendTurnstileToken.value) {
errors.value.turnstile = 'Please complete the verification'
errors.value.turnstile = t('auth.completeVerification')
return
}
@@ -358,12 +367,12 @@ function validateForm(): boolean {
errors.value.code = ''
if (!verifyCode.value.trim()) {
errors.value.code = 'Verification code is required'
errors.value.code = t('auth.codeRequired')
return false
}
if (!/^\d{6}$/.test(verifyCode.value.trim())) {
errors.value.code = 'Please enter a valid 6-digit code'
errors.value.code = t('auth.invalidCode')
return false
}
@@ -380,6 +389,12 @@ async function handleVerify(): Promise<void> {
isLoading.value = true
try {
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
errorMessage.value = buildEmailSuffixNotAllowedMessage()
appStore.showError(errorMessage.value)
return
}
// Register with verification code
await authStore.register({
email: email.value,
@@ -394,20 +409,14 @@ async function handleVerify(): Promise<void> {
sessionStorage.removeItem('register_data')
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.')
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
// Redirect to dashboard
await router.push('/dashboard')
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Verification failed. Please try again.'
}
errorMessage.value = buildAuthErrorMessage(error, {
fallback: t('auth.verifyFailed')
})
appStore.showError(errorMessage.value)
} finally {
@@ -422,6 +431,19 @@ function handleBack(): void {
// Go back to registration
router.push('/register')
}
function buildEmailSuffixNotAllowedMessage(): string {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(
registrationEmailSuffixWhitelist.value
)
if (normalizedWhitelist.length === 0) {
return t('auth.emailSuffixNotAllowed')
}
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
return t('auth.emailSuffixNotAllowedWithAllowed', {
suffixes: normalizedWhitelist.join(separator)
})
}
</script>
<style scoped>

View File

@@ -293,8 +293,13 @@ import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n()
const { t, locale } = useI18n()
// ==================== Router & Stores ====================
@@ -319,6 +324,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const linuxdoOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -370,6 +376,9 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
// Read promo code from URL parameter only if promo code is enabled
if (promoCodeEnabled.value) {
@@ -557,6 +566,19 @@ function validateEmail(email: string): boolean {
return emailRegex.test(email)
}
function buildEmailSuffixNotAllowedMessage(): string {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(
registrationEmailSuffixWhitelist.value
)
if (normalizedWhitelist.length === 0) {
return t('auth.emailSuffixNotAllowed')
}
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
return t('auth.emailSuffixNotAllowedWithAllowed', {
suffixes: normalizedWhitelist.join(separator)
})
}
function validateForm(): boolean {
// Reset errors
errors.email = ''
@@ -573,6 +595,11 @@ function validateForm(): boolean {
} else if (!validateEmail(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
} else if (
!isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value)
) {
errors.email = buildEmailSuffixNotAllowedMessage()
isValid = false
}
// Password validation
@@ -694,15 +721,9 @@ async function handleRegister(): Promise<void> {
}
// Handle registration error
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.registrationFailed')
}
errorMessage.value = buildAuthErrorMessage(error, {
fallback: t('auth.registrationFailed')
})
// Also show error toast
appStore.showError(errorMessage.value)

View File

@@ -0,0 +1,176 @@
<template>
<AppLayout>
<div class="custom-page-layout">
<div class="card flex-1 min-h-0 overflow-hidden">
<div v-if="loading" class="flex h-full items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if="!menuItem"
class="flex h-full items-center justify-center p-10 text-center"
>
<div class="max-w-md">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon name="link" size="lg" class="text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('customPage.notFoundTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('customPage.notFoundDesc') }}
</p>
</div>
</div>
<div v-else-if="!isValidUrl" class="flex h-full items-center justify-center p-10 text-center">
<div class="max-w-md">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon name="link" size="lg" class="text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('customPage.notConfiguredTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('customPage.notConfiguredDesc') }}
</p>
</div>
</div>
<div v-else class="custom-embed-shell">
<a
:href="embeddedUrl"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary btn-sm custom-open-fab"
>
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('customPage.openInNewTab') }}
</a>
<iframe
:src="embeddedUrl"
class="custom-embed-frame"
allowfullscreen
></iframe>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import { useAdminSettingsStore } from '@/stores/adminSettings'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
const { t } = useI18n()
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
const adminSettingsStore = useAdminSettingsStore()
const loading = ref(false)
const pageTheme = ref<'light' | 'dark'>('light')
let themeObserver: MutationObserver | null = null
const menuItemId = computed(() => route.params.id as string)
const menuItem = computed(() => {
const id = menuItemId.value
// Try public settings first (contains user-visible items)
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
const found = publicItems.find((item) => item.id === id) ?? null
if (found) return found
// For admin users, also check admin settings (contains admin-only items)
if (authStore.isAdmin) {
return adminSettingsStore.customMenuItems.find((item) => item.id === id) ?? null
}
return null
})
const embeddedUrl = computed(() => {
if (!menuItem.value) return ''
return buildEmbeddedUrl(
menuItem.value.url,
authStore.user?.id,
authStore.token,
pageTheme.value,
)
})
const isValidUrl = computed(() => {
const url = embeddedUrl.value
return url.startsWith('http://') || url.startsWith('https://')
})
onMounted(async () => {
pageTheme.value = detectTheme()
if (typeof document !== 'undefined') {
themeObserver = new MutationObserver(() => {
pageTheme.value = detectTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
if (appStore.publicSettingsLoaded) return
loading.value = true
try {
await appStore.fetchPublicSettings()
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (themeObserver) {
themeObserver.disconnect()
themeObserver = null
}
})
</script>
<style scoped>
.custom-page-layout {
@apply flex flex-col;
height: calc(100vh - 64px - 4rem);
}
.custom-embed-shell {
@apply relative;
@apply h-full w-full overflow-hidden rounded-2xl;
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
@apply p-0;
}
.custom-open-fab {
@apply absolute right-3 top-3 z-10;
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80;
}
.custom-embed-frame {
display: block;
margin: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
}
</style>

View File

@@ -1,6 +1,29 @@
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-wrap items-center gap-3">
<SearchInput
v-model="filterSearch"
:placeholder="t('keys.searchPlaceholder')"
class="w-full sm:w-64"
@search="onFilterChange"
/>
<Select
:model-value="filterGroupId"
class="w-40"
:options="groupFilterOptions"
@update:model-value="onGroupFilterChange"
/>
<Select
:model-value="filterStatus"
class="w-40"
:options="statusFilterOptions"
@update:model-value="onStatusFilterChange"
/>
</div>
</template>
<template #actions>
<div class="flex justify-end gap-3">
<button
@@ -137,6 +160,97 @@
</div>
</template>
<template #cell-rate_limit="{ row }">
<div v-if="row.rate_limit_5h > 0 || row.rate_limit_1d > 0 || row.rate_limit_7d > 0" class="space-y-1.5 min-w-[140px]">
<!-- 5h window -->
<div v-if="row.rate_limit_5h > 0">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">5h</span>
<span :class="[
'font-medium tabular-nums',
row.usage_5h >= row.rate_limit_5h ? 'text-red-500' :
row.usage_5h >= row.rate_limit_5h * 0.8 ? 'text-yellow-500' :
'text-gray-700 dark:text-gray-300'
]">
${{ row.usage_5h?.toFixed(2) || '0.00' }}/${{ row.rate_limit_5h?.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.usage_5h >= row.rate_limit_5h ? 'bg-red-500' :
row.usage_5h >= row.rate_limit_5h * 0.8 ? 'bg-yellow-500' :
'bg-emerald-500'
]"
:style="{ width: Math.min((row.usage_5h / row.rate_limit_5h) * 100, 100) + '%' }"
/>
</div>
</div>
<!-- 1d window -->
<div v-if="row.rate_limit_1d > 0">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">1d</span>
<span :class="[
'font-medium tabular-nums',
row.usage_1d >= row.rate_limit_1d ? 'text-red-500' :
row.usage_1d >= row.rate_limit_1d * 0.8 ? 'text-yellow-500' :
'text-gray-700 dark:text-gray-300'
]">
${{ row.usage_1d?.toFixed(2) || '0.00' }}/${{ row.rate_limit_1d?.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.usage_1d >= row.rate_limit_1d ? 'bg-red-500' :
row.usage_1d >= row.rate_limit_1d * 0.8 ? 'bg-yellow-500' :
'bg-emerald-500'
]"
:style="{ width: Math.min((row.usage_1d / row.rate_limit_1d) * 100, 100) + '%' }"
/>
</div>
</div>
<!-- 7d window -->
<div v-if="row.rate_limit_7d > 0">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">7d</span>
<span :class="[
'font-medium tabular-nums',
row.usage_7d >= row.rate_limit_7d ? 'text-red-500' :
row.usage_7d >= row.rate_limit_7d * 0.8 ? 'text-yellow-500' :
'text-gray-700 dark:text-gray-300'
]">
${{ row.usage_7d?.toFixed(2) || '0.00' }}/${{ row.rate_limit_7d?.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.usage_7d >= row.rate_limit_7d ? 'bg-red-500' :
row.usage_7d >= row.rate_limit_7d * 0.8 ? 'bg-yellow-500' :
'bg-emerald-500'
]"
:style="{ width: Math.min((row.usage_7d / row.rate_limit_7d) * 100, 100) + '%' }"
/>
</div>
</div>
<!-- Reset button -->
<button
v-if="row.usage_5h > 0 || row.usage_1d > 0 || row.usage_7d > 0"
@click.stop="confirmResetRateLimitFromTable(row)"
class="mt-0.5 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title="t('keys.resetRateLimitUsage')"
>
<Icon name="refresh" size="xs" />
{{ t('keys.resetUsage') }}
</button>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<template #cell-expires_at="{ value }">
<span v-if="value" :class="[
'text-sm',
@@ -452,6 +566,180 @@
</div>
</div>
<!-- Rate Limit Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.rateLimitSection') }}</label>
<button
type="button"
@click="formData.enable_rate_limit = !formData.enable_rate_limit"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_rate_limit ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_rate_limit ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="formData.enable_rate_limit" class="space-y-4 pt-2">
<p class="input-hint -mt-2">{{ t('keys.rateLimitHint') }}</p>
<!-- 5-Hour Limit -->
<div>
<label class="input-label">{{ t('keys.rateLimit5h') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.rate_limit_5h"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="'0'"
/>
</div>
<!-- Usage info (edit mode only) -->
<div v-if="showEditModal && selectedKey && selectedKey.rate_limit_5h > 0" class="mt-2">
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700 text-sm">
<span :class="[
'font-medium',
selectedKey.usage_5h >= selectedKey.rate_limit_5h ? 'text-red-500' :
selectedKey.usage_5h >= selectedKey.rate_limit_5h * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ selectedKey.usage_5h?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.rate_limit_5h?.toFixed(2) || '0.00' }}
</span>
</div>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
selectedKey.usage_5h >= selectedKey.rate_limit_5h ? 'bg-red-500' :
selectedKey.usage_5h >= selectedKey.rate_limit_5h * 0.8 ? 'bg-yellow-500' :
'bg-green-500'
]"
:style="{ width: Math.min((selectedKey.usage_5h / selectedKey.rate_limit_5h) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
<!-- Daily Limit -->
<div>
<label class="input-label">{{ t('keys.rateLimit1d') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.rate_limit_1d"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="'0'"
/>
</div>
<!-- Usage info (edit mode only) -->
<div v-if="showEditModal && selectedKey && selectedKey.rate_limit_1d > 0" class="mt-2">
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700 text-sm">
<span :class="[
'font-medium',
selectedKey.usage_1d >= selectedKey.rate_limit_1d ? 'text-red-500' :
selectedKey.usage_1d >= selectedKey.rate_limit_1d * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ selectedKey.usage_1d?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.rate_limit_1d?.toFixed(2) || '0.00' }}
</span>
</div>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
selectedKey.usage_1d >= selectedKey.rate_limit_1d ? 'bg-red-500' :
selectedKey.usage_1d >= selectedKey.rate_limit_1d * 0.8 ? 'bg-yellow-500' :
'bg-green-500'
]"
:style="{ width: Math.min((selectedKey.usage_1d / selectedKey.rate_limit_1d) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
<!-- 7-Day Limit -->
<div>
<label class="input-label">{{ t('keys.rateLimit7d') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.rate_limit_7d"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="'0'"
/>
</div>
<!-- Usage info (edit mode only) -->
<div v-if="showEditModal && selectedKey && selectedKey.rate_limit_7d > 0" class="mt-2">
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700 text-sm">
<span :class="[
'font-medium',
selectedKey.usage_7d >= selectedKey.rate_limit_7d ? 'text-red-500' :
selectedKey.usage_7d >= selectedKey.rate_limit_7d * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ selectedKey.usage_7d?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.rate_limit_7d?.toFixed(2) || '0.00' }}
</span>
</div>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
selectedKey.usage_7d >= selectedKey.rate_limit_7d ? 'bg-red-500' :
selectedKey.usage_7d >= selectedKey.rate_limit_7d * 0.8 ? 'bg-yellow-500' :
'bg-green-500'
]"
:style="{ width: Math.min((selectedKey.usage_7d / selectedKey.rate_limit_7d) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
<!-- Reset Rate Limit button (edit mode only) -->
<div v-if="showEditModal && selectedKey && (selectedKey.rate_limit_5h > 0 || selectedKey.rate_limit_1d > 0 || selectedKey.rate_limit_7d > 0)">
<button
type="button"
@click="confirmResetRateLimit"
class="btn btn-secondary text-sm"
>
{{ t('keys.resetRateLimitUsage') }}
</button>
</div>
</div>
</div>
<!-- Expiration Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
@@ -593,6 +881,18 @@
@cancel="showResetQuotaDialog = false"
/>
<!-- Reset Rate Limit Confirmation Dialog -->
<ConfirmDialog
:show="showResetRateLimitDialog"
:title="t('keys.resetRateLimitTitle')"
:message="t('keys.resetRateLimitConfirmMessage', { name: selectedKey?.name })"
:confirm-text="t('keys.reset')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="resetRateLimitUsage"
@cancel="showResetRateLimitDialog = false"
/>
<!-- Use Key Modal -->
<UseKeyModal
:show="showUseKeyModal"
@@ -708,6 +1008,7 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import SearchInput from '@/components/common/SearchInput.vue'
import Icon from '@/components/icons/Icon.vue'
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
@@ -743,6 +1044,7 @@ const columns = computed<Column[]>(() => [
{ key: 'key', label: t('keys.apiKey'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: false },
{ key: 'usage', label: t('keys.usage'), sortable: false },
{ key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false },
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
{ key: 'status', label: t('common.status'), sortable: true },
{ key: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true },
@@ -764,10 +1066,16 @@ const pagination = ref({
pages: 0
})
// Filter state
const filterSearch = ref('')
const filterStatus = ref('')
const filterGroupId = ref<string | number>('')
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showResetQuotaDialog = ref(false)
const showResetRateLimitDialog = ref(false)
const showUseKeyModal = ref(false)
const showCcsClientSelect = ref(false)
const pendingCcsRow = ref<ApiKey | null>(null)
@@ -806,6 +1114,11 @@ const formData = ref({
// Quota settings (empty = unlimited)
enable_quota: false,
quota: null as number | null,
// Rate limit settings
enable_rate_limit: false,
rate_limit_5h: null as number | null,
rate_limit_1d: null as number | null,
rate_limit_7d: null as number | null,
enable_expiration: false,
expiration_preset: '30' as '7' | '30' | '90' | 'custom',
expiration_date: ''
@@ -832,6 +1145,36 @@ const statusOptions = computed(() => [
{ value: 'inactive', label: t('common.inactive') }
])
// Filter dropdown options
const groupFilterOptions = computed(() => [
{ value: '', label: t('keys.allGroups') },
{ value: 0, label: t('keys.noGroup') },
...groups.value.map((g) => ({ value: g.id, label: g.name }))
])
const statusFilterOptions = computed(() => [
{ value: '', label: t('keys.allStatus') },
{ value: 'active', label: t('keys.status.active') },
{ value: 'inactive', label: t('keys.status.inactive') },
{ value: 'quota_exhausted', label: t('keys.status.quota_exhausted') },
{ value: 'expired', label: t('keys.status.expired') }
])
const onFilterChange = () => {
pagination.value.page = 1
loadApiKeys()
}
const onGroupFilterChange = (value: string | number | boolean | null) => {
filterGroupId.value = value as string | number
onFilterChange()
}
const onStatusFilterChange = (value: string | number | boolean | null) => {
filterStatus.value = value as string
onFilterChange()
}
// Convert groups to Select options format with rate multiplier and subscription type
const groupOptions = computed(() =>
groups.value.map((group) => ({
@@ -873,7 +1216,13 @@ const loadApiKeys = async () => {
const { signal } = controller
loading.value = true
try {
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, {
// Build filters
const filters: { search?: string; status?: string; group_id?: number | string } = {}
if (filterSearch.value) filters.search = filterSearch.value
if (filterStatus.value) filters.status = filterStatus.value
if (filterGroupId.value !== '') filters.group_id = filterGroupId.value
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, filters, {
signal
})
if (signal.aborted) return
@@ -966,6 +1315,10 @@ const editKey = (key: ApiKey) => {
ip_blacklist: (key.ip_blacklist || []).join('\n'),
enable_quota: key.quota > 0,
quota: key.quota > 0 ? key.quota : null,
enable_rate_limit: (key.rate_limit_5h > 0) || (key.rate_limit_1d > 0) || (key.rate_limit_7d > 0),
rate_limit_5h: key.rate_limit_5h || null,
rate_limit_1d: key.rate_limit_1d || null,
rate_limit_7d: key.rate_limit_7d || null,
enable_expiration: hasExpiration,
expiration_preset: 'custom',
expiration_date: key.expires_at ? formatDateTimeLocal(key.expires_at) : ''
@@ -1078,6 +1431,13 @@ const handleSubmit = async () => {
expiresAt = ''
}
// Calculate rate limit values (send 0 when toggle is off)
const rateLimitData = formData.value.enable_rate_limit ? {
rate_limit_5h: formData.value.rate_limit_5h && formData.value.rate_limit_5h > 0 ? formData.value.rate_limit_5h : 0,
rate_limit_1d: formData.value.rate_limit_1d && formData.value.rate_limit_1d > 0 ? formData.value.rate_limit_1d : 0,
rate_limit_7d: formData.value.rate_limit_7d && formData.value.rate_limit_7d > 0 ? formData.value.rate_limit_7d : 0,
} : { rate_limit_5h: 0, rate_limit_1d: 0, rate_limit_7d: 0 }
submitting.value = true
try {
if (showEditModal.value && selectedKey.value) {
@@ -1088,7 +1448,10 @@ const handleSubmit = async () => {
ip_whitelist: ipWhitelist,
ip_blacklist: ipBlacklist,
quota: quota,
expires_at: expiresAt
expires_at: expiresAt,
rate_limit_5h: rateLimitData.rate_limit_5h,
rate_limit_1d: rateLimitData.rate_limit_1d,
rate_limit_7d: rateLimitData.rate_limit_7d,
})
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
} else {
@@ -1100,7 +1463,8 @@ const handleSubmit = async () => {
ipWhitelist,
ipBlacklist,
quota,
expiresInDays
expiresInDays,
rateLimitData
)
appStore.showSuccess(t('keys.keyCreatedSuccess'))
// Only advance tour if active, on submit step, and creation succeeded
@@ -1154,6 +1518,10 @@ const closeModals = () => {
ip_blacklist: '',
enable_quota: false,
quota: null,
enable_rate_limit: false,
rate_limit_5h: null,
rate_limit_1d: null,
rate_limit_7d: null,
enable_expiration: false,
expiration_preset: '30',
expiration_date: ''
@@ -1190,6 +1558,37 @@ const resetQuotaUsed = async () => {
}
}
// Show reset rate limit confirmation dialog (from edit modal)
const confirmResetRateLimit = () => {
showResetRateLimitDialog.value = true
}
// Show reset rate limit confirmation dialog (from table row)
const confirmResetRateLimitFromTable = (row: ApiKey) => {
selectedKey.value = row
showResetRateLimitDialog.value = true
}
// Reset rate limit usage for an API key
const resetRateLimitUsage = async () => {
if (!selectedKey.value) return
showResetRateLimitDialog.value = false
try {
await keysAPI.update(selectedKey.value.id, { reset_rate_limit_usage: true })
appStore.showSuccess(t('keys.rateLimitResetSuccess'))
// Refresh key data
await loadApiKeys()
// Update the editing key with fresh data
const refreshedKey = apiKeys.value.find(k => k.id === selectedKey.value!.id)
if (refreshedKey) {
selectedKey.value = refreshedKey
}
} catch (error: any) {
const errorMsg = error.response?.data?.detail || t('keys.failedToResetRateLimit')
appStore.showError(errorMsg)
}
}
const importToCcswitch = (row: ApiKey) => {
const platform = row.group?.platform || 'anthropic'

View File

@@ -74,17 +74,12 @@ import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const PURCHASE_USER_ID_QUERY_KEY = 'user_id'
const PURCHASE_AUTH_TOKEN_QUERY_KEY = 'token'
const PURCHASE_THEME_QUERY_KEY = 'theme'
const PURCHASE_UI_MODE_QUERY_KEY = 'ui_mode'
const PURCHASE_UI_MODE_EMBEDDED = 'embedded'
const loading = ref(false)
const purchaseTheme = ref<'light' | 'dark'>('light')
let themeObserver: MutationObserver | null = null
@@ -93,37 +88,9 @@ const purchaseEnabled = computed(() => {
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
})
function detectTheme(): 'light' | 'dark' {
if (typeof document === 'undefined') return 'light'
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
}
function buildPurchaseUrl(
baseUrl: string,
userId?: number,
authToken?: string | null,
theme: 'light' | 'dark' = 'light',
): string {
if (!baseUrl) return baseUrl
try {
const url = new URL(baseUrl)
if (userId) {
url.searchParams.set(PURCHASE_USER_ID_QUERY_KEY, String(userId))
}
if (authToken) {
url.searchParams.set(PURCHASE_AUTH_TOKEN_QUERY_KEY, authToken)
}
url.searchParams.set(PURCHASE_THEME_QUERY_KEY, theme)
url.searchParams.set(PURCHASE_UI_MODE_QUERY_KEY, PURCHASE_UI_MODE_EMBEDDED)
return url.toString()
} catch {
return baseUrl
}
}
const purchaseUrl = computed(() => {
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
return buildPurchaseUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value)
return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value)
})
const isValidUrl = computed(() => {

View File

@@ -0,0 +1,369 @@
<template>
<div class="sora-root">
<!-- Sora 页面内容 -->
<div class="sora-page">
<!-- 功能未启用提示 -->
<div v-if="!soraEnabled" class="sora-not-enabled">
<svg class="sora-not-enabled-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
<h2 class="sora-not-enabled-title">{{ t('sora.notEnabled') }}</h2>
<p class="sora-not-enabled-desc">{{ t('sora.notEnabledDesc') }}</p>
</div>
<!-- Sora 主界面 -->
<template v-else>
<!-- 自定义 Sora 头部 -->
<header class="sora-header">
<div class="sora-header-left">
<!-- 返回主页按钮 -->
<router-link :to="dashboardPath" class="sora-back-btn" :title="t('common.back')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 19l-7-7 7-7" />
</svg>
</router-link>
<nav class="sora-nav-tabs">
<button
v-for="tab in tabs"
:key="tab.key"
:class="['sora-nav-tab', activeTab === tab.key && 'active']"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</nav>
</div>
<div class="sora-header-right">
<SoraQuotaBar v-if="quota" :quota="quota" />
<div v-if="activeTaskCount > 0" class="sora-queue-indicator">
<span class="sora-queue-dot" :class="{ busy: hasGeneratingTask }"></span>
<span>{{ activeTaskCount }} {{ t('sora.queueTasks') }}</span>
</div>
</div>
</header>
<!-- 内容区域 -->
<main class="sora-main">
<SoraGeneratePage
v-show="activeTab === 'generate'"
@task-count-change="onTaskCountChange"
/>
<SoraLibraryPage
v-show="activeTab === 'library'"
@switch-to-generate="activeTab = 'generate'"
/>
</main>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore } from '@/stores'
import SoraQuotaBar from '@/components/sora/SoraQuotaBar.vue'
import SoraGeneratePage from '@/components/sora/SoraGeneratePage.vue'
import SoraLibraryPage from '@/components/sora/SoraLibraryPage.vue'
import soraAPI, { type QuotaInfo } from '@/api/sora'
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const soraEnabled = computed(() => appStore.cachedPublicSettings?.sora_client_enabled ?? false)
const activeTab = ref<'generate' | 'library'>('generate')
const quota = ref<QuotaInfo | null>(null)
const activeTaskCount = ref(0)
const hasGeneratingTask = ref(false)
const dashboardPath = computed(() => (authStore.isAdmin ? '/admin/dashboard' : '/dashboard'))
const tabs = computed(() => [
{ key: 'generate' as const, label: t('sora.tabGenerate') },
{ key: 'library' as const, label: t('sora.tabLibrary') }
])
function onTaskCountChange(counts: { active: number; generating: boolean }) {
activeTaskCount.value = counts.active
hasGeneratingTask.value = counts.generating
}
onMounted(async () => {
if (!soraEnabled.value) return
try {
quota.value = await soraAPI.getQuota()
} catch {
// 配额查询失败不阻塞页面
}
})
</script>
<style scoped>
/* ============================================================
Sora 主题 CSS 变量 — 亮色模式(跟随应用主题)
============================================================ */
.sora-root {
--sora-bg-primary: #F9FAFB;
--sora-bg-secondary: #FFFFFF;
--sora-bg-tertiary: #F3F4F6;
--sora-bg-elevated: #FFFFFF;
--sora-bg-hover: #E5E7EB;
--sora-bg-input: #FFFFFF;
--sora-text-primary: #111827;
--sora-text-secondary: #6B7280;
--sora-text-tertiary: #9CA3AF;
--sora-text-muted: #D1D5DB;
--sora-accent-primary: #14b8a6;
--sora-accent-secondary: #0d9488;
--sora-accent-gradient: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
--sora-accent-gradient-hover: linear-gradient(135deg, #2dd4bf 0%, #14b8a6 100%);
--sora-success: #10B981;
--sora-warning: #F59E0B;
--sora-error: #EF4444;
--sora-info: #3B82F6;
--sora-border-color: #E5E7EB;
--sora-border-subtle: #F3F4F6;
--sora-radius-sm: 8px;
--sora-radius-md: 12px;
--sora-radius-lg: 16px;
--sora-radius-xl: 20px;
--sora-radius-full: 9999px;
--sora-shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--sora-shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--sora-shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
--sora-shadow-glow: 0 0 20px rgba(20,184,166,0.25);
--sora-transition-fast: 150ms ease;
--sora-transition-normal: 250ms ease;
--sora-header-height: 56px;
--sora-header-bg: rgba(249, 250, 251, 0.85);
--sora-placeholder-gradient: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%);
--sora-modal-backdrop: rgba(0, 0, 0, 0.4);
min-height: 100vh;
background: var(--sora-bg-primary);
color: var(--sora-text-primary);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ============================================================
页面布局
============================================================ */
.sora-page {
width: 100%;
}
/* ============================================================
头部导航栏
============================================================ */
.sora-header {
position: sticky;
top: 0;
z-index: 30;
height: var(--sora-header-height);
background: var(--sora-header-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--sora-border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
}
.sora-header-left {
display: flex;
align-items: center;
gap: 24px;
}
.sora-header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* 返回按钮 */
.sora-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--sora-radius-sm);
color: var(--sora-text-secondary);
text-decoration: none;
transition: all var(--sora-transition-fast);
}
.sora-back-btn:hover {
background: var(--sora-bg-tertiary);
color: var(--sora-text-primary);
}
/* Tab 导航 */
.sora-nav-tabs {
display: flex;
gap: 4px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full);
padding: 3px;
}
.sora-nav-tab {
padding: 6px 20px;
border-radius: var(--sora-radius-full);
font-size: 13px;
font-weight: 500;
color: var(--sora-text-secondary);
background: none;
border: none;
cursor: pointer;
transition: all var(--sora-transition-fast);
user-select: none;
}
.sora-nav-tab:hover {
color: var(--sora-text-primary);
}
.sora-nav-tab.active {
background: var(--sora-bg-tertiary);
color: var(--sora-text-primary);
}
/* 队列指示器 */
.sora-queue-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full);
font-size: 12px;
color: var(--sora-text-secondary);
}
.sora-queue-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--sora-success);
animation: sora-pulse-dot 2s ease-in-out infinite;
}
.sora-queue-dot.busy {
background: var(--sora-warning);
}
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ============================================================
主内容区
============================================================ */
.sora-main {
min-height: calc(100vh - var(--sora-header-height));
}
/* ============================================================
功能未启用
============================================================ */
.sora-not-enabled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 40px;
}
.sora-not-enabled-icon {
width: 64px;
height: 64px;
color: var(--sora-text-tertiary);
margin-bottom: 16px;
}
.sora-not-enabled-title {
font-size: 20px;
font-weight: 600;
color: var(--sora-text-secondary);
margin-bottom: 8px;
}
.sora-not-enabled-desc {
font-size: 14px;
color: var(--sora-text-tertiary);
max-width: 400px;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 900px) {
.sora-header {
padding: 0 16px;
}
.sora-header-left {
gap: 12px;
}
}
@media (max-width: 600px) {
.sora-nav-tab {
padding: 5px 14px;
font-size: 12px;
}
}
/* 滚动条 */
.sora-root ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.sora-root ::-webkit-scrollbar-track {
background: transparent;
}
.sora-root ::-webkit-scrollbar-thumb {
background: var(--sora-bg-hover);
border-radius: 3px;
}
.sora-root ::-webkit-scrollbar-thumb:hover {
background: var(--sora-text-tertiary);
}
</style>
<style>
/* 暗色模式:必须明确命中 .sora-root避免被 scoped 编译后的变量覆盖问题 */
html.dark .sora-root {
--sora-bg-primary: #020617;
--sora-bg-secondary: #0f172a;
--sora-bg-tertiary: #1e293b;
--sora-bg-elevated: #1e293b;
--sora-bg-hover: #334155;
--sora-bg-input: #0f172a;
--sora-text-primary: #f1f5f9;
--sora-text-secondary: #94a3b8;
--sora-text-tertiary: #64748b;
--sora-text-muted: #475569;
--sora-border-color: #334155;
--sora-border-subtle: #1e293b;
--sora-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--sora-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--sora-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--sora-shadow-glow: 0 0 20px rgba(20, 184, 166, 0.3);
--sora-header-bg: rgba(2, 6, 23, 0.85);
--sora-placeholder-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 50%, #020617 100%);
--sora-modal-backdrop: rgba(0, 0, 0, 0.7);
}
</style>

View File

@@ -166,13 +166,9 @@
<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'
"
:class="getRequestTypeBadgeClass(row)"
>
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
{{ getRequestTypeLabel(row) }}
</span>
</template>
@@ -473,12 +469,13 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { Column } from '@/components/common/types'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { Column } from '@/components/common/types'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
const { t } = useI18n()
const appStore = useAppStore()
@@ -577,6 +574,30 @@ const formatUserAgent = (ua: string): string => {
return ua
}
const getRequestTypeLabel = (log: UsageLog): string => {
const requestType = resolveUsageRequestType(log)
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 = (log: UsageLog): string => {
const requestType = resolveUsageRequestType(log)
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 getRequestTypeExportText = (log: UsageLog): string => {
const requestType = resolveUsageRequestType(log)
if (requestType === 'ws_v2') return 'WS'
if (requestType === 'stream') return 'Stream'
if (requestType === 'sync') return 'Sync'
return 'Unknown'
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
@@ -768,7 +789,7 @@ const exportToCSV = async () => {
log.api_key?.name || '',
log.model,
formatReasoningEffort(log.reasoning_effort),
log.stream ? 'Stream' : 'Sync',
getRequestTypeExportText(log),
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,