mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-12 02:54:44 +08:00
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:
80
frontend/src/api/__tests__/sora.spec.ts
Normal file
80
frontend/src/api/__tests__/sora.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
frontend/src/api/admin/apiKeys.ts
Normal file
33
frontend/src/api/admin/apiKeys.ts
Normal 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
|
||||
@@ -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,
|
||||
|
||||
332
frontend/src/api/admin/dataManagement.ts
Normal file
332
frontend/src/api/admin/dataManagement.ts
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
307
frontend/src/api/sora.ts
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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/SetupToken(RPM 配置仅在此条件下显示)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]>([
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
146
frontend/src/components/common/ImageUpload.vue
Normal file
146
frontend/src/components/common/ImageUpload.vue
Normal 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>
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
217
frontend/src/components/sora/SoraDownloadDialog.vue
Normal file
217
frontend/src/components/sora/SoraDownloadDialog.vue
Normal 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>
|
||||
430
frontend/src/components/sora/SoraGeneratePage.vue
Normal file
430
frontend/src/components/sora/SoraGeneratePage.vue
Normal 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>
|
||||
576
frontend/src/components/sora/SoraLibraryPage.vue
Normal file
576
frontend/src/components/sora/SoraLibraryPage.vue
Normal 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>
|
||||
282
frontend/src/components/sora/SoraMediaPreview.vue
Normal file
282
frontend/src/components/sora/SoraMediaPreview.vue
Normal 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>
|
||||
39
frontend/src/components/sora/SoraNoStorageWarning.vue
Normal file
39
frontend/src/components/sora/SoraNoStorageWarning.vue
Normal 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>
|
||||
609
frontend/src/components/sora/SoraProgressCard.vue
Normal file
609
frontend/src/components/sora/SoraProgressCard.vue
Normal 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>
|
||||
738
frontend/src/components/sora/SoraPromptBar.vue
Normal file
738
frontend/src/components/sora/SoraPromptBar.vue
Normal 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 ID(family + 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>
|
||||
87
frontend/src/components/sora/SoraQuotaBar.vue
Normal file
87
frontend/src/components/sora/SoraQuotaBar.vue
Normal 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>
|
||||
18
frontend/src/composables/__tests__/useModelWhitelist.spec.ts
Normal file
18
frontend/src/composables/__tests__/useModelWhitelist.spec.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
49
frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts
Normal file
49
frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '公告',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
47
frontend/src/utils/__tests__/authError.spec.ts
Normal file
47
frontend/src/utils/__tests__/authError.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
55
frontend/src/utils/__tests__/openaiWsMode.spec.ts
Normal file
55
frontend/src/utils/__tests__/openaiWsMode.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
77
frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
Normal file
77
frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
90
frontend/src/utils/__tests__/soraTokenParser.spec.ts
Normal file
90
frontend/src/utils/__tests__/soraTokenParser.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
25
frontend/src/utils/authError.ts
Normal file
25
frontend/src/utils/authError.ts
Normal 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
|
||||
}
|
||||
46
frontend/src/utils/embedded-url.ts
Normal file
46
frontend/src/utils/embedded-url.ts
Normal 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'
|
||||
}
|
||||
61
frontend/src/utils/openaiWsMode.ts
Normal file
61
frontend/src/utils/openaiWsMode.ts
Normal 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
|
||||
}
|
||||
115
frontend/src/utils/registrationEmailPolicy.ts
Normal file
115
frontend/src/utils/registrationEmailPolicy.ts
Normal 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)
|
||||
}
|
||||
6
frontend/src/utils/sanitize.ts
Normal file
6
frontend/src/utils/sanitize.ts
Normal 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 } })
|
||||
}
|
||||
308
frontend/src/utils/soraTokenParser.ts
Normal file
308
frontend/src/utils/soraTokenParser.ts
Normal 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
|
||||
}
|
||||
}
|
||||
33
frontend/src/utils/usageRequestType.ts
Normal file
33
frontend/src/utils/usageRequestType.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
514
frontend/src/views/admin/DataManagementView.vue
Normal file
514
frontend/src/views/admin/DataManagementView.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
91
frontend/src/views/admin/ops/utils/errorDetailResponse.ts
Normal file
91
frontend/src/views/admin/ops/utils/errorDetailResponse.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
176
frontend/src/views/user/CustomPageView.vue
Normal file
176
frontend/src/views/user/CustomPageView.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
369
frontend/src/views/user/SoraView.vue
Normal file
369
frontend/src/views/user/SoraView.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user